Merge lp:~edwin-grubbs/launchpad/bug-638924-testfix into lp:launchpad

Proposed by Edwin Grubbs on 2010-12-15
Status: Merged
Approved by: Edwin Grubbs on 2010-12-16
Approved revision: no longer in the source branch.
Merged at revision: 12090
Proposed branch: lp:~edwin-grubbs/launchpad/bug-638924-testfix
Merge into: lp:launchpad
Prerequisite: lp:~edwin-grubbs/launchpad/bug-638924-milestone-page-eager-loading
Diff against target: 1228 lines (+865/-128)
8 files modified
lib/lp/bugs/interfaces/bugtask.py (+9/-1)
lib/lp/bugs/model/bugtask.py (+116/-8)
lib/lp/bugs/tests/test_bugsearch_conjoined.py (+340/-0)
lib/lp/registry/browser/milestone.py (+5/-19)
lib/lp/registry/browser/tests/test_milestone.py (+212/-4)
lib/lp/registry/interfaces/person.py (+18/-0)
lib/lp/registry/model/person.py (+137/-94)
lib/lp/registry/tests/test_person.py (+28/-2)
To merge this branch: bzr merge lp:~edwin-grubbs/launchpad/bug-638924-testfix
Reviewer Review Type Date Requested Status
Jelmer Vernooij (community) code 2010-12-15 Approve on 2010-12-16
Review via email: mp+43791@code.launchpad.net

Commit Message

[r=jelmer][ui=none][bug=638924] Fix timeouts on milestone +index page when it has a lot of bugtasks.

Description of the Change

Summary
-------

Most of this branch was already landed and then rolled back. I am
including a smaller diff of just the changes that I have made since the
first time it was reviewed.

The query was excluding all the bugtasks for a bug, if the bug had even
one conjoined master. However, the conjoined master bugtask should only
exclude the conjoined slave. The conjoined master is the bugtask on the
project's development focus series or the distro's currentseries. The
conjoined slave is the bugtask on the corresponding project or distro.
In the Launchpad web UI, the milestone for the project or distro bugtask
will be hidden if there is a conjoined master bugtask, but in the
database, the project or distro bugtask is always updated with the same
milestone as the conjoined master bugtask.

Tests
-----

./bin/test -vv -t 'test_bugsearch_conjoined|object-milestones.txt'

Demo and Q/A
------------

These three pages should not timeout.
    * https://launchpad.net/launchpad-project/+milestone/10.10
    * https://launchpad.net/launchpad-registry/+milestone/10.10
    * https://launchpad.net/ubuntu/+milestone/ubuntu-9.10/+index

Diff
----

=== modified file 'lib/lp/bugs/model/bugtask.py'
--- lib/lp/bugs/model/bugtask.py 2010-12-10 15:06:12 +0000
+++ lib/lp/bugs/model/bugtask.py 2010-12-15 16:40:51 +0000
@@ -39,6 +39,7 @@
     In,
     Join,
     LeftJoin,
+ Not,
     Or,
     Select,
     SQL,
@@ -1601,6 +1602,78 @@
             raise AssertionError(
                 'Unrecognized status value: %s' % repr(status))

+ def _buildExcludeConjoinedClause(self, milestone):
+ """Exclude bugtasks with a conjoined master.
+
+ This search option only makes sense when searching for bugtasks
+ for a milestone. Only bugtasks for a project or a distribution
+ can have a conjoined master bugtask, which is a bugtask on the
+ project's development focus series or the distribution's
+ currentseries. The project bugtask or the distribution bugtask
+ will always have the same milestone set as its conjoined master
+ bugtask, if it exists on the bug. Therefore, this prevents a lot
+ of bugs having two bugtasks listed in the results. However, it
+ is ok if a bug has multiple bugtasks in the results as long as
+ those other bugtasks are on other series.
+ """
+ # XXX: EdwinGrubbs 2010-12-15 bug=682989
+ # (ConjoinedMaster.bug == X) produces the wrong sql, but
+ # (ConjoinedMaster.bugID == X) works right. This bug applies to
+ # all foreign keys on the ClassAlias.
+
+ # Perform a LEFT JOIN to the conjoined master bugtask. If the
+ # conjoined master is not null, it gets filtered out.
+ ConjoinedMaster = ClassAlias(BugTask, 'ConjoinedMaster')
+ extra_clauses = ["ConjoinedMaster.id IS NULL"]
+ if milestone.distribution is not None:
+ current_series = milestone.distribution.currentseries
+ join = LeftJoin(
+ ConjoinedMaster,
+ And(ConjoinedMaster.bugID == BugTask.bugID,
+ BugTask.distributionID == milestone.distribution.id,
+ ConjoinedMaster.distroseriesID == current_series.id,
+ Not(ConjoinedMaster.status.is_in(
+ BugTask._NON_CONJOINED_STATUSES))))
+ join_tables = [(ConjoinedMaster, join)]
+ else:
+ # Prevent import loop.
+ from lp.registry.model.milestone import Milestone
+ from lp.registry.model.product import Product
+ if IProjectGroupMilestone.providedBy(milestone):
+ # Since an IProjectGroupMilestone could have bugs with
+ # bugtasks on two different projects, the project
+ # bugtask is only excluded by a development focus series
+ # bugtask on the same project.
+ joins = [
+ Join(Milestone, BugTask.milestone == Milestone.id),
+ LeftJoin(Product, BugTask.product == Product.id),
+ LeftJoin(
+ ConjoinedMaster,
+ And(ConjoinedMaster.bugID == BugTask.bugID,
+ ConjoinedMaster.productseriesID
+ == Product.development_focusID,
+ Not(ConjoinedMaster.status.is_in(
+ BugTask._NON_CONJOINED_STATUSES)))),
+ ]
+ # join.right is the table name.
+ join_tables = [(join.right, join) for join in joins]
+ elif milestone.product is not None:
+ dev_focus_id = (
+ milestone.product.development_focusID)
+ join = LeftJoin(
+ ConjoinedMaster,
+ And(ConjoinedMaster.bugID == BugTask.bugID,
+ BugTask.productID == milestone.product.id,
+ ConjoinedMaster.productseriesID == dev_focus_id,
+ Not(ConjoinedMaster.status.is_in(
+ BugTask._NON_CONJOINED_STATUSES))))
+ join_tables = [(ConjoinedMaster, join)]
+ else:
+ raise AssertionError(
+ "A milestone must always have either a project, "
+ "project group, or distribution")
+ return (join_tables, extra_clauses)
+
     def buildQuery(self, params):
         """Build and return an SQL query with the given parameters.

@@ -1671,41 +1744,10 @@
             extra_clauses.append("BugTask.milestone %s" % where_cond)

             if params.exclude_conjoined_tasks:
- # Perform a LEFT JOIN to the conjoined master bugtask.
- # If the conjoined master is not null, it gets filtered
- # out.
- ConjoinedMaster = ClassAlias(BugTask, 'ConjoinedMaster')
- extra_clauses.append("ConjoinedMaster.id IS NULL")
- if params.milestone.distribution is not None:
- current_series = (
- params.milestone.distribution.currentseries)
- join = LeftJoin(
- ConjoinedMaster,
- And(ConjoinedMaster.bug == BugTask.bugID,
- ConjoinedMaster.distroseries == current_series.id,
- ))
- join_tables.append((ConjoinedMaster, join))
- else:
- # Prevent import loop.
- from lp.registry.model.product import Product
- if IProjectGroupMilestone.providedBy(params.milestone):
- dev_focus_ids = list(
- IStore(Product).find(
- Product.development_focusID,
- Product.project == params.milestone.target))
- elif params.milestone.product is not None:
- dev_focus_ids = [
- params.milestone.product.development_focusID]
- else:
- raise AssertionError(
- "A milestone must always have either a project, "
- "project group, or distribution")
- join = LeftJoin(
- ConjoinedMaster,
- And(ConjoinedMaster.bug == BugTask.bugID,
- ConjoinedMaster.productseriesID.is_in(
- dev_focus_ids)))
- join_tables.append((ConjoinedMaster, join))
+ tables, clauses = self._buildExcludeConjoinedClause(
+ params.milestone)
+ join_tables += tables
+ extra_clauses += clauses
         elif params.exclude_conjoined_tasks:
             raise ValueError(
                 "BugTaskSearchParam.exclude_conjoined cannot be True if "

=== added file 'lib/lp/bugs/tests/test_bugsearch_conjoined.py'
--- lib/lp/bugs/tests/test_bugsearch_conjoined.py 1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/tests/test_bugsearch_conjoined.py 2010-12-15 05:42:42 +0000
@@ -0,0 +1,340 @@
+# Copyright 2010 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test for the exclude_conjoined_tasks param for BugTaskSearchParams."""
+
+__metaclass__ = type
+
+__all__ = []
+
+from storm.store import Store
+from testtools.matchers import Equals
+from zope.component import getUtility
+
+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
+from canonical.testing.layers import DatabaseFunctionalLayer
+from lp.bugs.interfaces.bugtask import (
+ BugTaskSearchParams,
+ BugTaskStatus,
+ IBugTaskSet,
+ )
+from lp.registry.interfaces.series import SeriesStatus
+from lp.testing import (
+ person_logged_in,
+ StormStatementRecorder,
+ TestCaseWithFactory,
+ )
+from lp.testing.matchers import HasQueryCount
+
+
+class TestSearchBase(TestCaseWithFactory):
+ """Tests of exclude_conjoined_tasks param."""
+
+ def makeBug(self, milestone):
+ bug = self.factory.makeBug(
+ product=milestone.product, distribution=milestone.distribution)
+ with person_logged_in(milestone.target.owner):
+ bug.default_bugtask.transitionToMilestone(
+ milestone, milestone.target.owner)
+ return bug
+
+
+class TestProjectExcludeConjoinedMasterSearch(TestSearchBase):
+ """Tests of exclude_conjoined_tasks param for project milestones."""
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(TestProjectExcludeConjoinedMasterSearch, self).setUp()
+ self.bugtask_set = getUtility(IBugTaskSet)
+ self.product = self.factory.makeProduct()
+ self.milestone = self.factory.makeMilestone(
+ product=self.product, name='foo')
+ self.bug_count = 2
+ self.bugs = [
+ self.makeBug(self.milestone)
+ for i in range(self.bug_count)]
+ self.params = BugTaskSearchParams(
+ user=None, milestone=self.milestone, exclude_conjoined_tasks=True)
+
+ def test_search_results_count_simple(self):
+ # Verify number of results with no conjoined masters.
+ self.assertEqual(
+ self.bug_count,
+ self.bugtask_set.search(self.params).count())
+
+ def test_search_query_count(self):
+ # Verify query count.
+ Store.of(self.milestone).flush()
+ with StormStatementRecorder() as recorder:
+ list(self.bugtask_set.search(self.params))
+ self.assertThat(recorder, HasQueryCount(Equals(1)))
+
+ def test_search_results_count_with_other_productseries_tasks(self):
+ # Test with zero conjoined masters and bugtasks targeted to
+ # productseries that are not the development focus.
+ productseries = self.factory.makeProductSeries(product=self.product)
+ extra_bugtasks = 0
+ for bug in self.bugs:
+ extra_bugtasks += 1
+ bugtask = self.factory.makeBugTask(bug=bug, target=productseries)
+ with person_logged_in(self.product.owner):
+ bugtask.transitionToMilestone(
+ self.milestone, self.product.owner)
+ self.assertEqual(
+ self.bug_count + extra_bugtasks,
+ self.bugtask_set.search(self.params).count())
+
+ def test_search_results_count_with_conjoined_masters(self):
+ # Test with increasing numbers of conjoined masters.
+ # The conjoined masters will exclude the conjoined slaves from
+ # the results.
+ tasks = list(self.bugtask_set.search(self.params))
+ for bug in self.bugs:
+ # The product bugtask is in the results before the conjoined
+ # master is added.
+ self.assertIn(
+ (bug.id, self.product),
+ [(task.bug.id, task.product) for task in tasks])
+ bugtask = self.factory.makeBugTask(
+ bug=bug, target=self.product.development_focus)
+ tasks = list(self.bugtask_set.search(self.params))
+ # The product bugtask is excluded from the results.
+ self.assertEqual(self.bug_count, len(tasks))
+ self.assertNotIn(
+ (bug.id, self.product),
+ [(task.bug.id, task.product) for task in tasks])
+
+ def test_search_results_count_with_wontfix_conjoined_masters(self):
+ # Test that conjoined master bugtasks in the WONTFIX status
+ # don't cause the bug to be excluded.
+ masters = [
+ self.factory.makeBugTask(
+ bug=bug, target=self.product.development_focus)
+ for bug in self.bugs]
+ tasks = list(self.bugtask_set.search(self.params))
+ wontfix_masters_count = 0
+ for bugtask in masters:
+ wontfix_masters_count += 1
+ self.assertNotIn(
+ (bugtask.bug.id, self.product),
+ [(task.bug.id, task.product) for task in tasks])
+ with person_logged_in(self.product.owner):
+ bugtask.transitionToStatus(
+ BugTaskStatus.WONTFIX, self.product.owner)
+ tasks = list(self.bugtask_set.search(self.params))
+ self.assertEqual(self.bug_count + wontfix_masters_count,
+ len(tasks))
+ self.assertIn(
+ (bugtask.bug.id, self.product),
+ [(task.bug.id, task.product) for task in tasks])
+
+
+class TestProjectGroupExcludeConjoinedMasterSearch(TestSearchBase):
+ """Tests of exclude_conjoined_tasks param for project group milestones."""
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(TestProjectGroupExcludeConjoinedMasterSearch, self).setUp()
+ self.bugtask_set = getUtility(IBugTaskSet)
+ self.projectgroup = self.factory.makeProject()
+ self.bug_count = 2
+ self.bug_products = {}
+ for i in range(self.bug_count):
+ product = self.factory.makeProduct(project=self.projectgroup)
+ product_milestone = self.factory.makeMilestone(
+ product=product, name='foo')
+ bug = self.makeBug(product_milestone)
+ self.bug_products[bug] = product
+ self.milestone = self.projectgroup.getMilestone('foo')
+ self.params = BugTaskSearchParams(
+ user=None, milestone=self.milestone, exclude_conjoined_tasks=True)
+
+ def test_search_results_count_simple(self):
+ # Verify number of results with no conjoined masters.
+ self.assertEqual(
+ self.bug_count,
+ self.bugtask_set.search(self.params).count())
+
+ def test_search_query_count(self):
+ # Verify query count.
+ Store.of(self.projectgroup).flush()
+ with StormStatementRecorder() as recorder:
+ list(self.bugtask_set.search(self.params))
+ self.assertThat(recorder, HasQueryCount(Equals(1)))
+
+ def test_search_results_count_with_other_productseries_tasks(self):
+ # Test with zero conjoined masters and bugtasks targeted to
+ # productseries that are not the development focus.
+ extra_bugtasks = 0
+ for bug, product in self.bug_products.items():
+ extra_bugtasks += 1
+ productseries = self.factory.makeProductSeries(product=product)
+ bugtask = self.factory.makeBugTask(bug=bug, target=productseries)
+ with person_logged_in(product.owner):
+ bugtask.transitionToMilestone(
+ product.getMilestone(self.milestone.name), product.owner)
+ self.assertEqual(
+ self.bug_count + extra_bugtasks,
+ self.bugtask_set.search(self.params).count())
+
+ def test_search_results_count_with_conjoined_masters(self):
+ # Test with increasing numbers of conjoined masters.
+ tasks = list(self.bugtask_set.search(self.params))
+ for bug, product in self.bug_products.items():
+ self.assertIn(
+ (bug.id, product),
+ [(task.bug.id, task.product) for task in tasks])
+ self.factory.makeBugTask(
+ bug=bug, target=product.development_focus)
+ tasks = list(self.bugtask_set.search(self.params))
+ self.assertEqual(
+ self.bug_count,
+ self.bugtask_set.search(self.params).count())
+ self.assertNotIn(
+ (bug.id, product),
+ [(task.bug.id, task.product) for task in tasks])
+
+ def test_search_results_count_with_irrelevant_conjoined_masters(self):
+ # Verify that a conjoined master in one project of the project
+ # group doesn't cause a bugtask on another project in the group
+ # to be excluded from the project group milestone's bugs.
+ extra_bugtasks = 0
+ for bug, product in self.bug_products.items():
+ extra_bugtasks += 1
+ other_product = self.factory.makeProduct(
+ project=self.projectgroup)
+ # Create a new milestone with the same name.
+ other_product_milestone = self.factory.makeMilestone(
+ product=other_product,
+ name=bug.default_bugtask.milestone.name)
+ # Add bugtask on the new product and select the milestone.
+ other_product_bugtask = self.factory.makeBugTask(
+ bug=bug, target=other_product)
+ with person_logged_in(other_product.owner):
+ other_product_bugtask.transitionToMilestone(
+ other_product_milestone, other_product.owner)
+ # Add conjoined master for the milestone on the new product.
+ self.factory.makeBugTask(
+ bug=bug, target=other_product.development_focus)
+ # The bug count should not change, since we are just adding
+ # bugtasks on existing bugs.
+ self.assertEqual(
+ self.bug_count + extra_bugtasks,
+ self.bugtask_set.search(self.params).count())
+
+ def test_search_results_count_with_wontfix_conjoined_masters(self):
+ # Test that conjoined master bugtasks in the WONTFIX status
+ # don't cause the bug to be excluded.
+ masters = [
+ self.factory.makeBugTask(
+ bug=bug, target=product.development_focus)
+ for bug, product in self.bug_products.items()]
+ unexcluded_count = 0
+ for bugtask in masters:
+ unexcluded_count += 1
+ with person_logged_in(product.owner):
+ bugtask.transitionToStatus(
+ BugTaskStatus.WONTFIX, bugtask.target.owner)
+ self.assertEqual(
+ self.bug_count + unexcluded_count,
+ self.bugtask_set.search(self.params).count())
+
+
+class TestDistributionExcludeConjoinedMasterSearch(TestSearchBase):
+ """Tests of exclude_conjoined_tasks param for distribution milestones."""
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(TestDistributionExcludeConjoinedMasterSearch, self).setUp()
+ self.bugtask_set = getUtility(IBugTaskSet)
+ self.distro = getUtility(ILaunchpadCelebrities).ubuntu
+ self.milestone = self.factory.makeMilestone(
+ distribution=self.distro, name='foo')
+ self.bug_count = 2
+ self.bugs = [
+ self.makeBug(self.milestone)
+ for i in range(self.bug_count)]
+ self.params = BugTaskSearchParams(
+ user=None, milestone=self.milestone, exclude_conjoined_tasks=True)
+
+ def test_search_results_count_simple(self):
+ # Verify number of results with no conjoined masters.
+ self.assertEqual(
+ self.bug_count,
+ self.bugtask_set.search(self.params).count())
+
+ def test_search_query_count(self):
+ # Verify query count.
+ # 1. Query all the distroseries to determine the distro's
+ # currentseries.
+ # 2. Query the bugtasks.
+ Store.of(self.milestone).flush()
+ with StormStatementRecorder() as recorder:
+ list(self.bugtask_set.search(self.params))
+ self.assertThat(recorder, HasQueryCount(Equals(2)))
+
+ def test_search_results_count_with_other_productseries_tasks(self):
+ # Test with zero conjoined masters and bugtasks targeted to
+ # productseries that are not the development focus.
+ distroseries = self.factory.makeDistroSeries(
+ distribution=self.distro, status=SeriesStatus.SUPPORTED)
+ extra_bugtasks = 0
+ for bug in self.bugs:
+ extra_bugtasks += 1
+ bugtask = self.factory.makeBugTask(bug=bug, target=distroseries)
+ with person_logged_in(self.distro.owner):
+ bugtask.transitionToMilestone(
+ self.milestone, self.distro.owner)
+ self.assertEqual(
+ self.bug_count + extra_bugtasks,
+ self.bugtask_set.search(self.params).count())
+
+ def test_search_results_count_with_conjoined_masters(self):
+ # Test with increasing numbers of conjoined masters.
+ tasks = list(self.bugtask_set.search(self.params))
+ for bug in self.bugs:
+ # The distro bugtask is in the results before the conjoined
+ # master is added.
+ self.assertIn(
+ (bug.id, self.distro),
+ [(task.bug.id, task.distribution) for task in tasks])
+ self.factory.makeBugTask(
+ bug=bug, target=self.distro.currentseries)
+ tasks = list(self.bugtask_set.search(self.params))
+ # The product bugtask is excluded from the results.
+ self.assertEqual(self.bug_count, len(tasks))
+ self.assertNotIn(
+ (bug.id, self.distro),
+ [(task.bug.id, task.distribution) for task in tasks])
+
+ def test_search_results_count_with_wontfix_conjoined_masters(self):
+ # Test that conjoined master bugtasks in the WONTFIX status
+ # don't cause the bug to be excluded.
+ masters = [
+ self.factory.makeBugTask(
+ bug=bug, target=self.distro.currentseries)
+ for bug in self.bugs]
+ wontfix_masters_count = 0
+ tasks = list(self.bugtask_set.search(self.params))
+ for bugtask in masters:
+ wontfix_masters_count += 1
+ # The distro bugtask is still excluded by the conjoined
+ # master.
+ self.assertNotIn(
+ (bugtask.bug.id, self.distro),
+ [(task.bug.id, task.distribution) for task in tasks])
+ with person_logged_in(self.distro.owner):
+ bugtask.transitionToStatus(
+ BugTaskStatus.WONTFIX, self.distro.owner)
+ tasks = list(self.bugtask_set.search(self.params))
+ self.assertEqual(
+ self.bug_count + wontfix_masters_count,
+ self.bugtask_set.search(self.params).count())
+ # The distro bugtask is no longer excluded by the conjoined
+ # master, since its status is WONTFIX.
+ self.assertIn(
+ (bugtask.bug.id, self.distro),
+ [(task.bug.id, task.distribution) for task in tasks])

To post a comment you must log in.
Jelmer Vernooij (jelmer) wrote :

Sorry this took so long. My SQL is rusty and I wasn't really familiar with the problem space.

Great to see it's very well tested.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
2--- lib/lp/bugs/interfaces/bugtask.py 2010-12-10 07:52:21 +0000
3+++ lib/lp/bugs/interfaces/bugtask.py 2010-12-15 16:58:33 +0000
4@@ -1145,7 +1145,8 @@
5 hardware_owner_is_subscribed_to_bug=False,
6 hardware_is_linked_to_bug=False,
7 linked_branches=None, structural_subscriber=None,
8- modified_since=None, created_since=None):
9+ modified_since=None, created_since=None,
10+ exclude_conjoined_tasks=False):
11
12 self.bug = bug
13 self.searchtext = searchtext
14@@ -1191,6 +1192,7 @@
15 self.structural_subscriber = structural_subscriber
16 self.modified_since = modified_since
17 self.created_since = created_since
18+ self.exclude_conjoined_tasks = exclude_conjoined_tasks
19
20 def setProduct(self, product):
21 """Set the upstream context on which to filter the search."""
22@@ -1530,6 +1532,12 @@
23 This takes into account bug subscription filters.
24 """
25
26+ def getPrecachedNonConjoinedBugTasks(user, milestone):
27+ """List of non-conjoined bugtasks targeted to the milestone.
28+
29+ The assignee and the assignee's validity are precached.
30+ """
31+
32
33 def valid_remote_bug_url(value):
34 """Verify that the URL is to a bug to a known bug tracker."""
35
36=== modified file 'lib/lp/bugs/model/bugtask.py'
37--- lib/lp/bugs/model/bugtask.py 2010-12-10 07:52:21 +0000
38+++ lib/lp/bugs/model/bugtask.py 2010-12-15 16:58:33 +0000
39@@ -39,18 +39,16 @@
40 In,
41 Join,
42 LeftJoin,
43+ Not,
44 Or,
45 Select,
46 SQL,
47 )
48+from storm.info import ClassAlias
49 from storm.store import (
50 EmptyResultSet,
51 Store,
52 )
53-from storm.zope.interfaces import (
54- IResultSet,
55- ISQLObjectResultSet,
56- )
57 from zope.component import getUtility
58 from zope.interface import (
59 alsoProvides,
60@@ -142,6 +140,7 @@
61 from lp.registry.interfaces.milestone import IProjectGroupMilestone
62 from lp.registry.interfaces.person import (
63 IPerson,
64+ IPersonSet,
65 validate_person,
66 validate_public_person,
67 )
68@@ -1490,16 +1489,23 @@
69 from lp.bugs.model.bug import Bug
70 from lp.bugs.model.bugbranch import BugBranch
71
72- bug_ids = list(set(bugtask.bugID for bugtask in bugtasks))
73+ bug_ids = set(bugtask.bugID for bugtask in bugtasks)
74 bug_ids_with_specifications = set(IStore(SpecificationBug).find(
75 SpecificationBug.bugID,
76 SpecificationBug.bugID.is_in(bug_ids)))
77 bug_ids_with_branches = set(IStore(BugBranch).find(
78 BugBranch.bugID, BugBranch.bugID.is_in(bug_ids)))
79
80- # Cache all bugs at once to avoid one query per bugtask. We
81- # could rely on the Storm cache, but this is explicit.
82- bugs = dict(IStore(Bug).find((Bug.id, Bug), Bug.id.is_in(bug_ids)))
83+ # Check if the bugs are cached. If not, cache all uncached bugs
84+ # at once to avoid one query per bugtask. We could rely on the
85+ # Storm cache, but this is explicit.
86+ bugs = dict(
87+ (bug.id, bug)
88+ for bug in IStore(Bug).find(Bug, Bug.id.is_in(bug_ids)).cached())
89+ uncached_ids = bug_ids.difference(bug_id for bug_id in bugs)
90+ if len(uncached_ids) > 0:
91+ bugs.update(dict(IStore(Bug).find((Bug.id, Bug),
92+ Bug.id.is_in(uncached_ids))))
93
94 badge_properties = {}
95 for bugtask in bugtasks:
96@@ -1596,6 +1602,78 @@
97 raise AssertionError(
98 'Unrecognized status value: %s' % repr(status))
99
100+ def _buildExcludeConjoinedClause(self, milestone):
101+ """Exclude bugtasks with a conjoined master.
102+
103+ This search option only makes sense when searching for bugtasks
104+ for a milestone. Only bugtasks for a project or a distribution
105+ can have a conjoined master bugtask, which is a bugtask on the
106+ project's development focus series or the distribution's
107+ currentseries. The project bugtask or the distribution bugtask
108+ will always have the same milestone set as its conjoined master
109+ bugtask, if it exists on the bug. Therefore, this prevents a lot
110+ of bugs having two bugtasks listed in the results. However, it
111+ is ok if a bug has multiple bugtasks in the results as long as
112+ those other bugtasks are on other series.
113+ """
114+ # XXX: EdwinGrubbs 2010-12-15 bug=682989
115+ # (ConjoinedMaster.bug == X) produces the wrong sql, but
116+ # (ConjoinedMaster.bugID == X) works right. This bug applies to
117+ # all foreign keys on the ClassAlias.
118+
119+ # Perform a LEFT JOIN to the conjoined master bugtask. If the
120+ # conjoined master is not null, it gets filtered out.
121+ ConjoinedMaster = ClassAlias(BugTask, 'ConjoinedMaster')
122+ extra_clauses = ["ConjoinedMaster.id IS NULL"]
123+ if milestone.distribution is not None:
124+ current_series = milestone.distribution.currentseries
125+ join = LeftJoin(
126+ ConjoinedMaster,
127+ And(ConjoinedMaster.bugID == BugTask.bugID,
128+ BugTask.distributionID == milestone.distribution.id,
129+ ConjoinedMaster.distroseriesID == current_series.id,
130+ Not(ConjoinedMaster.status.is_in(
131+ BugTask._NON_CONJOINED_STATUSES))))
132+ join_tables = [(ConjoinedMaster, join)]
133+ else:
134+ # Prevent import loop.
135+ from lp.registry.model.milestone import Milestone
136+ from lp.registry.model.product import Product
137+ if IProjectGroupMilestone.providedBy(milestone):
138+ # Since an IProjectGroupMilestone could have bugs with
139+ # bugtasks on two different projects, the project
140+ # bugtask is only excluded by a development focus series
141+ # bugtask on the same project.
142+ joins = [
143+ Join(Milestone, BugTask.milestone == Milestone.id),
144+ LeftJoin(Product, BugTask.product == Product.id),
145+ LeftJoin(
146+ ConjoinedMaster,
147+ And(ConjoinedMaster.bugID == BugTask.bugID,
148+ ConjoinedMaster.productseriesID
149+ == Product.development_focusID,
150+ Not(ConjoinedMaster.status.is_in(
151+ BugTask._NON_CONJOINED_STATUSES)))),
152+ ]
153+ # join.right is the table name.
154+ join_tables = [(join.right, join) for join in joins]
155+ elif milestone.product is not None:
156+ dev_focus_id = (
157+ milestone.product.development_focusID)
158+ join = LeftJoin(
159+ ConjoinedMaster,
160+ And(ConjoinedMaster.bugID == BugTask.bugID,
161+ BugTask.productID == milestone.product.id,
162+ ConjoinedMaster.productseriesID == dev_focus_id,
163+ Not(ConjoinedMaster.status.is_in(
164+ BugTask._NON_CONJOINED_STATUSES))))
165+ join_tables = [(ConjoinedMaster, join)]
166+ else:
167+ raise AssertionError(
168+ "A milestone must always have either a project, "
169+ "project group, or distribution")
170+ return (join_tables, extra_clauses)
171+
172 def buildQuery(self, params):
173 """Build and return an SQL query with the given parameters.
174
175@@ -1665,6 +1743,17 @@
176 where_cond = search_value_to_where_condition(params.milestone)
177 extra_clauses.append("BugTask.milestone %s" % where_cond)
178
179+ if params.exclude_conjoined_tasks:
180+ tables, clauses = self._buildExcludeConjoinedClause(
181+ params.milestone)
182+ join_tables += tables
183+ extra_clauses += clauses
184+ elif params.exclude_conjoined_tasks:
185+ raise ValueError(
186+ "BugTaskSearchParam.exclude_conjoined cannot be True if "
187+ "BugTaskSearchParam.milestone is not set")
188+
189+
190 if params.project:
191 # Prevent circular import problems.
192 from lp.registry.model.product import Product
193@@ -2309,6 +2398,25 @@
194 """See `IBugTaskSet`."""
195 return self._search(BugTask.bugID, [], params).result_set
196
197+ def getPrecachedNonConjoinedBugTasks(self, user, milestone):
198+ """See `IBugTaskSet`."""
199+ params = BugTaskSearchParams(
200+ user, milestone=milestone,
201+ orderby=['status', '-importance', 'id'],
202+ omit_dupes=True, exclude_conjoined_tasks=True)
203+ non_conjoined_slaves = self.search(params)
204+
205+ def cache_people(rows):
206+ assignee_ids = set(
207+ bug_task.assigneeID for bug_task in rows)
208+ assignees = getUtility(IPersonSet).getPrecachedPersonsFromIDs(
209+ assignee_ids, need_validity=True)
210+ # Execute query to load storm cache.
211+ list(assignees)
212+
213+ return DecoratedResultSet(
214+ non_conjoined_slaves, pre_iter_hook=cache_people)
215+
216 def createTask(self, bug, owner, product=None, productseries=None,
217 distribution=None, distroseries=None,
218 sourcepackagename=None,
219
220=== added file 'lib/lp/bugs/tests/test_bugsearch_conjoined.py'
221--- lib/lp/bugs/tests/test_bugsearch_conjoined.py 1970-01-01 00:00:00 +0000
222+++ lib/lp/bugs/tests/test_bugsearch_conjoined.py 2010-12-15 16:58:33 +0000
223@@ -0,0 +1,340 @@
224+# Copyright 2010 Canonical Ltd. This software is licensed under the
225+# GNU Affero General Public License version 3 (see the file LICENSE).
226+
227+"""Test for the exclude_conjoined_tasks param for BugTaskSearchParams."""
228+
229+__metaclass__ = type
230+
231+__all__ = []
232+
233+from storm.store import Store
234+from testtools.matchers import Equals
235+from zope.component import getUtility
236+
237+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
238+from canonical.testing.layers import DatabaseFunctionalLayer
239+from lp.bugs.interfaces.bugtask import (
240+ BugTaskSearchParams,
241+ BugTaskStatus,
242+ IBugTaskSet,
243+ )
244+from lp.registry.interfaces.series import SeriesStatus
245+from lp.testing import (
246+ person_logged_in,
247+ StormStatementRecorder,
248+ TestCaseWithFactory,
249+ )
250+from lp.testing.matchers import HasQueryCount
251+
252+
253+class TestSearchBase(TestCaseWithFactory):
254+ """Tests of exclude_conjoined_tasks param."""
255+
256+ def makeBug(self, milestone):
257+ bug = self.factory.makeBug(
258+ product=milestone.product, distribution=milestone.distribution)
259+ with person_logged_in(milestone.target.owner):
260+ bug.default_bugtask.transitionToMilestone(
261+ milestone, milestone.target.owner)
262+ return bug
263+
264+
265+class TestProjectExcludeConjoinedMasterSearch(TestSearchBase):
266+ """Tests of exclude_conjoined_tasks param for project milestones."""
267+
268+ layer = DatabaseFunctionalLayer
269+
270+ def setUp(self):
271+ super(TestProjectExcludeConjoinedMasterSearch, self).setUp()
272+ self.bugtask_set = getUtility(IBugTaskSet)
273+ self.product = self.factory.makeProduct()
274+ self.milestone = self.factory.makeMilestone(
275+ product=self.product, name='foo')
276+ self.bug_count = 2
277+ self.bugs = [
278+ self.makeBug(self.milestone)
279+ for i in range(self.bug_count)]
280+ self.params = BugTaskSearchParams(
281+ user=None, milestone=self.milestone, exclude_conjoined_tasks=True)
282+
283+ def test_search_results_count_simple(self):
284+ # Verify number of results with no conjoined masters.
285+ self.assertEqual(
286+ self.bug_count,
287+ self.bugtask_set.search(self.params).count())
288+
289+ def test_search_query_count(self):
290+ # Verify query count.
291+ Store.of(self.milestone).flush()
292+ with StormStatementRecorder() as recorder:
293+ list(self.bugtask_set.search(self.params))
294+ self.assertThat(recorder, HasQueryCount(Equals(1)))
295+
296+ def test_search_results_count_with_other_productseries_tasks(self):
297+ # Test with zero conjoined masters and bugtasks targeted to
298+ # productseries that are not the development focus.
299+ productseries = self.factory.makeProductSeries(product=self.product)
300+ extra_bugtasks = 0
301+ for bug in self.bugs:
302+ extra_bugtasks += 1
303+ bugtask = self.factory.makeBugTask(bug=bug, target=productseries)
304+ with person_logged_in(self.product.owner):
305+ bugtask.transitionToMilestone(
306+ self.milestone, self.product.owner)
307+ self.assertEqual(
308+ self.bug_count + extra_bugtasks,
309+ self.bugtask_set.search(self.params).count())
310+
311+ def test_search_results_count_with_conjoined_masters(self):
312+ # Test with increasing numbers of conjoined masters.
313+ # The conjoined masters will exclude the conjoined slaves from
314+ # the results.
315+ tasks = list(self.bugtask_set.search(self.params))
316+ for bug in self.bugs:
317+ # The product bugtask is in the results before the conjoined
318+ # master is added.
319+ self.assertIn(
320+ (bug.id, self.product),
321+ [(task.bug.id, task.product) for task in tasks])
322+ bugtask = self.factory.makeBugTask(
323+ bug=bug, target=self.product.development_focus)
324+ tasks = list(self.bugtask_set.search(self.params))
325+ # The product bugtask is excluded from the results.
326+ self.assertEqual(self.bug_count, len(tasks))
327+ self.assertNotIn(
328+ (bug.id, self.product),
329+ [(task.bug.id, task.product) for task in tasks])
330+
331+ def test_search_results_count_with_wontfix_conjoined_masters(self):
332+ # Test that conjoined master bugtasks in the WONTFIX status
333+ # don't cause the bug to be excluded.
334+ masters = [
335+ self.factory.makeBugTask(
336+ bug=bug, target=self.product.development_focus)
337+ for bug in self.bugs]
338+ tasks = list(self.bugtask_set.search(self.params))
339+ wontfix_masters_count = 0
340+ for bugtask in masters:
341+ wontfix_masters_count += 1
342+ self.assertNotIn(
343+ (bugtask.bug.id, self.product),
344+ [(task.bug.id, task.product) for task in tasks])
345+ with person_logged_in(self.product.owner):
346+ bugtask.transitionToStatus(
347+ BugTaskStatus.WONTFIX, self.product.owner)
348+ tasks = list(self.bugtask_set.search(self.params))
349+ self.assertEqual(self.bug_count + wontfix_masters_count,
350+ len(tasks))
351+ self.assertIn(
352+ (bugtask.bug.id, self.product),
353+ [(task.bug.id, task.product) for task in tasks])
354+
355+
356+class TestProjectGroupExcludeConjoinedMasterSearch(TestSearchBase):
357+ """Tests of exclude_conjoined_tasks param for project group milestones."""
358+
359+ layer = DatabaseFunctionalLayer
360+
361+ def setUp(self):
362+ super(TestProjectGroupExcludeConjoinedMasterSearch, self).setUp()
363+ self.bugtask_set = getUtility(IBugTaskSet)
364+ self.projectgroup = self.factory.makeProject()
365+ self.bug_count = 2
366+ self.bug_products = {}
367+ for i in range(self.bug_count):
368+ product = self.factory.makeProduct(project=self.projectgroup)
369+ product_milestone = self.factory.makeMilestone(
370+ product=product, name='foo')
371+ bug = self.makeBug(product_milestone)
372+ self.bug_products[bug] = product
373+ self.milestone = self.projectgroup.getMilestone('foo')
374+ self.params = BugTaskSearchParams(
375+ user=None, milestone=self.milestone, exclude_conjoined_tasks=True)
376+
377+ def test_search_results_count_simple(self):
378+ # Verify number of results with no conjoined masters.
379+ self.assertEqual(
380+ self.bug_count,
381+ self.bugtask_set.search(self.params).count())
382+
383+ def test_search_query_count(self):
384+ # Verify query count.
385+ Store.of(self.projectgroup).flush()
386+ with StormStatementRecorder() as recorder:
387+ list(self.bugtask_set.search(self.params))
388+ self.assertThat(recorder, HasQueryCount(Equals(1)))
389+
390+ def test_search_results_count_with_other_productseries_tasks(self):
391+ # Test with zero conjoined masters and bugtasks targeted to
392+ # productseries that are not the development focus.
393+ extra_bugtasks = 0
394+ for bug, product in self.bug_products.items():
395+ extra_bugtasks += 1
396+ productseries = self.factory.makeProductSeries(product=product)
397+ bugtask = self.factory.makeBugTask(bug=bug, target=productseries)
398+ with person_logged_in(product.owner):
399+ bugtask.transitionToMilestone(
400+ product.getMilestone(self.milestone.name), product.owner)
401+ self.assertEqual(
402+ self.bug_count + extra_bugtasks,
403+ self.bugtask_set.search(self.params).count())
404+
405+ def test_search_results_count_with_conjoined_masters(self):
406+ # Test with increasing numbers of conjoined masters.
407+ tasks = list(self.bugtask_set.search(self.params))
408+ for bug, product in self.bug_products.items():
409+ self.assertIn(
410+ (bug.id, product),
411+ [(task.bug.id, task.product) for task in tasks])
412+ self.factory.makeBugTask(
413+ bug=bug, target=product.development_focus)
414+ tasks = list(self.bugtask_set.search(self.params))
415+ self.assertEqual(
416+ self.bug_count,
417+ self.bugtask_set.search(self.params).count())
418+ self.assertNotIn(
419+ (bug.id, product),
420+ [(task.bug.id, task.product) for task in tasks])
421+
422+ def test_search_results_count_with_irrelevant_conjoined_masters(self):
423+ # Verify that a conjoined master in one project of the project
424+ # group doesn't cause a bugtask on another project in the group
425+ # to be excluded from the project group milestone's bugs.
426+ extra_bugtasks = 0
427+ for bug, product in self.bug_products.items():
428+ extra_bugtasks += 1
429+ other_product = self.factory.makeProduct(
430+ project=self.projectgroup)
431+ # Create a new milestone with the same name.
432+ other_product_milestone = self.factory.makeMilestone(
433+ product=other_product,
434+ name=bug.default_bugtask.milestone.name)
435+ # Add bugtask on the new product and select the milestone.
436+ other_product_bugtask = self.factory.makeBugTask(
437+ bug=bug, target=other_product)
438+ with person_logged_in(other_product.owner):
439+ other_product_bugtask.transitionToMilestone(
440+ other_product_milestone, other_product.owner)
441+ # Add conjoined master for the milestone on the new product.
442+ self.factory.makeBugTask(
443+ bug=bug, target=other_product.development_focus)
444+ # The bug count should not change, since we are just adding
445+ # bugtasks on existing bugs.
446+ self.assertEqual(
447+ self.bug_count + extra_bugtasks,
448+ self.bugtask_set.search(self.params).count())
449+
450+ def test_search_results_count_with_wontfix_conjoined_masters(self):
451+ # Test that conjoined master bugtasks in the WONTFIX status
452+ # don't cause the bug to be excluded.
453+ masters = [
454+ self.factory.makeBugTask(
455+ bug=bug, target=product.development_focus)
456+ for bug, product in self.bug_products.items()]
457+ unexcluded_count = 0
458+ for bugtask in masters:
459+ unexcluded_count += 1
460+ with person_logged_in(product.owner):
461+ bugtask.transitionToStatus(
462+ BugTaskStatus.WONTFIX, bugtask.target.owner)
463+ self.assertEqual(
464+ self.bug_count + unexcluded_count,
465+ self.bugtask_set.search(self.params).count())
466+
467+
468+class TestDistributionExcludeConjoinedMasterSearch(TestSearchBase):
469+ """Tests of exclude_conjoined_tasks param for distribution milestones."""
470+
471+ layer = DatabaseFunctionalLayer
472+
473+ def setUp(self):
474+ super(TestDistributionExcludeConjoinedMasterSearch, self).setUp()
475+ self.bugtask_set = getUtility(IBugTaskSet)
476+ self.distro = getUtility(ILaunchpadCelebrities).ubuntu
477+ self.milestone = self.factory.makeMilestone(
478+ distribution=self.distro, name='foo')
479+ self.bug_count = 2
480+ self.bugs = [
481+ self.makeBug(self.milestone)
482+ for i in range(self.bug_count)]
483+ self.params = BugTaskSearchParams(
484+ user=None, milestone=self.milestone, exclude_conjoined_tasks=True)
485+
486+ def test_search_results_count_simple(self):
487+ # Verify number of results with no conjoined masters.
488+ self.assertEqual(
489+ self.bug_count,
490+ self.bugtask_set.search(self.params).count())
491+
492+ def test_search_query_count(self):
493+ # Verify query count.
494+ # 1. Query all the distroseries to determine the distro's
495+ # currentseries.
496+ # 2. Query the bugtasks.
497+ Store.of(self.milestone).flush()
498+ with StormStatementRecorder() as recorder:
499+ list(self.bugtask_set.search(self.params))
500+ self.assertThat(recorder, HasQueryCount(Equals(2)))
501+
502+ def test_search_results_count_with_other_productseries_tasks(self):
503+ # Test with zero conjoined masters and bugtasks targeted to
504+ # productseries that are not the development focus.
505+ distroseries = self.factory.makeDistroSeries(
506+ distribution=self.distro, status=SeriesStatus.SUPPORTED)
507+ extra_bugtasks = 0
508+ for bug in self.bugs:
509+ extra_bugtasks += 1
510+ bugtask = self.factory.makeBugTask(bug=bug, target=distroseries)
511+ with person_logged_in(self.distro.owner):
512+ bugtask.transitionToMilestone(
513+ self.milestone, self.distro.owner)
514+ self.assertEqual(
515+ self.bug_count + extra_bugtasks,
516+ self.bugtask_set.search(self.params).count())
517+
518+ def test_search_results_count_with_conjoined_masters(self):
519+ # Test with increasing numbers of conjoined masters.
520+ tasks = list(self.bugtask_set.search(self.params))
521+ for bug in self.bugs:
522+ # The distro bugtask is in the results before the conjoined
523+ # master is added.
524+ self.assertIn(
525+ (bug.id, self.distro),
526+ [(task.bug.id, task.distribution) for task in tasks])
527+ self.factory.makeBugTask(
528+ bug=bug, target=self.distro.currentseries)
529+ tasks = list(self.bugtask_set.search(self.params))
530+ # The product bugtask is excluded from the results.
531+ self.assertEqual(self.bug_count, len(tasks))
532+ self.assertNotIn(
533+ (bug.id, self.distro),
534+ [(task.bug.id, task.distribution) for task in tasks])
535+
536+ def test_search_results_count_with_wontfix_conjoined_masters(self):
537+ # Test that conjoined master bugtasks in the WONTFIX status
538+ # don't cause the bug to be excluded.
539+ masters = [
540+ self.factory.makeBugTask(
541+ bug=bug, target=self.distro.currentseries)
542+ for bug in self.bugs]
543+ wontfix_masters_count = 0
544+ tasks = list(self.bugtask_set.search(self.params))
545+ for bugtask in masters:
546+ wontfix_masters_count += 1
547+ # The distro bugtask is still excluded by the conjoined
548+ # master.
549+ self.assertNotIn(
550+ (bugtask.bug.id, self.distro),
551+ [(task.bug.id, task.distribution) for task in tasks])
552+ with person_logged_in(self.distro.owner):
553+ bugtask.transitionToStatus(
554+ BugTaskStatus.WONTFIX, self.distro.owner)
555+ tasks = list(self.bugtask_set.search(self.params))
556+ self.assertEqual(
557+ self.bug_count + wontfix_masters_count,
558+ self.bugtask_set.search(self.params).count())
559+ # The distro bugtask is no longer excluded by the conjoined
560+ # master, since its status is WONTFIX.
561+ self.assertIn(
562+ (bugtask.bug.id, self.distro),
563+ [(task.bug.id, task.distribution) for task in tasks])
564
565=== modified file 'lib/lp/registry/browser/milestone.py'
566--- lib/lp/registry/browser/milestone.py 2010-12-10 07:52:21 +0000
567+++ lib/lp/registry/browser/milestone.py 2010-12-15 16:58:33 +0000
568@@ -41,7 +41,6 @@
569 precache_permission_for_objects,
570 )
571 from canonical.launchpad.webapp.breadcrumb import Breadcrumb
572-from canonical.launchpad.webapp.interfaces import ILaunchBag
573 from canonical.launchpad.webapp.menu import (
574 ApplicationMenu,
575 ContextMenu,
576@@ -57,7 +56,6 @@
577 )
578 from lp.bugs.browser.bugtask import BugTaskListingItem
579 from lp.bugs.interfaces.bugtask import (
580- BugTaskSearchParams,
581 IBugTaskSet,
582 )
583 from lp.registry.browser import (
584@@ -249,23 +247,11 @@
585 @cachedproperty
586 def _bugtasks(self):
587 """The list of non-conjoined bugtasks targeted to this milestone."""
588- user = getUtility(ILaunchBag).user
589- params = BugTaskSearchParams(user, milestone=self.context,
590- orderby=['status', '-importance', 'id'],
591- omit_dupes=True)
592- tasks = getUtility(IBugTaskSet).search(params)
593- # We could replace all the code below with a simple
594- # >>> [task for task in tasks if task.conjoined_master is None]
595- # But that'd cause one extra hit to the DB for every bugtask returned
596- # by the search above, so we do a single query to get all of a task's
597- # siblings here and use that to find whether or not a given bugtask
598- # has a conjoined master.
599- bugs_and_tasks = getUtility(IBugTaskSet).getBugTasks(
600- [task.bug.id for task in tasks])
601- non_conjoined_slaves = []
602- for task in tasks:
603- if task.getConjoinedMaster(bugs_and_tasks[task.bug]) is None:
604- non_conjoined_slaves.append(task)
605+ # Put the results in a list so that iterating over it multiple
606+ # times in this method does not make multiple queries.
607+ non_conjoined_slaves = list(
608+ getUtility(IBugTaskSet).getPrecachedNonConjoinedBugTasks(
609+ self.user, self.context))
610 # Checking bug permissions is expensive. We know from the query that
611 # the user has at least launchpad.View on the bugtasks and their bugs.
612 precache_permission_for_objects(
613
614=== modified file 'lib/lp/registry/browser/tests/test_milestone.py'
615--- lib/lp/registry/browser/tests/test_milestone.py 2010-12-10 07:52:21 +0000
616+++ lib/lp/registry/browser/tests/test_milestone.py 2010-12-15 16:58:33 +0000
617@@ -5,9 +5,13 @@
618
619 __metaclass__ = type
620
621+from textwrap import dedent
622+
623 from testtools.matchers import LessThan
624 from zope.component import getUtility
625
626+from canonical.config import config
627+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
628 from canonical.launchpad.webapp import canonical_url
629 from canonical.testing.layers import DatabaseFunctionalLayer
630 from lp.bugs.interfaces.bugtask import IBugTaskSet
631@@ -15,14 +19,16 @@
632 ANONYMOUS,
633 login,
634 login_person,
635+ login_team,
636 logout,
637 person_logged_in,
638+ StormStatementRecorder,
639 TestCaseWithFactory,
640 )
641+from lp.testing._webservice import QueryCollector
642 from lp.testing.matchers import HasQueryCount
643 from lp.testing.memcache import MemcacheTestCase
644 from lp.testing.views import create_initialized_view
645-from lp.testing._webservice import QueryCollector
646
647
648 class TestMilestoneViews(TestCaseWithFactory):
649@@ -163,10 +169,89 @@
650 self.assertEqual(0, product.development_focus.all_bugtasks.count())
651
652
653-class TestMilestoneIndex(TestCaseWithFactory):
654+class TestQueryCountBase(TestCaseWithFactory):
655+
656+ def assert_bugtasks_query_count(self, milestone, bugtask_count,
657+ query_limit):
658+ # Assert that the number of queries run by view.bugtasks is low.
659+ self.add_bug(bugtask_count)
660+ login_person(self.owner)
661+ view = create_initialized_view(milestone, '+index')
662+ # Eliminate permission check for the admin team from the
663+ # recorded queries by loading it now. If the test ever breaks,
664+ # the person fixing it won't waste time trying to track this
665+ # query down.
666+ getUtility(ILaunchpadCelebrities).admin
667+ with StormStatementRecorder() as recorder:
668+ bugtasks = list(view.bugtasks)
669+ self.assertThat(recorder, HasQueryCount(LessThan(query_limit)))
670+ self.assertEqual(bugtask_count, len(bugtasks))
671+
672+ def assert_milestone_page_query_count(self, milestone, query_limit):
673+ collector = QueryCollector()
674+ collector.register()
675+ try:
676+ milestone_url = canonical_url(milestone)
677+ browser = self.getUserBrowser(user=self.owner)
678+ browser.open(milestone_url)
679+ self.assertThat(collector, HasQueryCount(LessThan(query_limit)))
680+ finally:
681+ # Unregister now in case this method is called multiple
682+ # times in a single test.
683+ collector.unregister()
684+
685+
686+class TestProjectMilestoneIndexQueryCount(TestQueryCountBase):
687
688 layer = DatabaseFunctionalLayer
689
690+ def setUp(self):
691+ super(TestProjectMilestoneIndexQueryCount, self).setUp()
692+ # Increase cache size so that the query counts aren't affected
693+ # by objects being removed from the cache early.
694+ config.push('storm-cache', dedent('''
695+ [launchpad]
696+ storm_cache_size: 1000
697+ '''))
698+ self.addCleanup(config.pop, 'storm-cache')
699+ self.owner = self.factory.makePerson(name='product-owner')
700+ self.product = self.factory.makeProduct(owner=self.owner)
701+ login_person(self.product.owner)
702+ self.milestone = self.factory.makeMilestone(
703+ productseries=self.product.development_focus)
704+
705+ def add_bug(self, count):
706+ login_person(self.product.owner)
707+ for i in range(count):
708+ bug = self.factory.makeBug(product=self.product)
709+ bug.bugtasks[0].transitionToMilestone(
710+ self.milestone, self.product.owner)
711+ # This is necessary to test precaching of assignees.
712+ bug.bugtasks[0].transitionToAssignee(
713+ self.factory.makePerson())
714+ logout()
715+
716+ def test_bugtasks_queries(self):
717+ # The view.bugtasks attribute will make four queries:
718+ # 1. Load bugtasks and bugs.
719+ # 2. Load assignees (Person, Account, and EmailAddress).
720+ # 3. Load links to specifications.
721+ # 4. Load links to branches.
722+ bugtask_count = 10
723+ self.assert_bugtasks_query_count(
724+ self.milestone, bugtask_count, query_limit=5)
725+
726+ def test_milestone_eager_loading(self):
727+ # Verify that the number of queries does not increase with more
728+ # bugs with different assignees.
729+ query_limit = 34
730+ self.add_bug(3)
731+ self.assert_milestone_page_query_count(
732+ self.milestone, query_limit=query_limit)
733+ self.add_bug(10)
734+ self.assert_milestone_page_query_count(
735+ self.milestone, query_limit=query_limit)
736+
737 def test_more_private_bugs_query_count_is_constant(self):
738 # This test tests that as we add more private bugs to a milestone
739 # index page, the number of queries issued by the page does not
740@@ -175,6 +260,7 @@
741 # is very large already, if the test fails due to such a change,
742 # please cut some more of the existing fat out of it rather than
743 # increasing the cap.
744+ page_query_limit = 34
745 product = self.factory.makeProduct()
746 login_person(product.owner)
747 milestone = self.factory.makeMilestone(
748@@ -199,8 +285,6 @@
749 collector = QueryCollector()
750 collector.register()
751 self.addCleanup(collector.unregister)
752- # This page is rather high in queries : 46 as a baseline. 20100817
753- page_query_limit = 46
754 browser.open(milestone_url)
755 # Check that the test found the bug
756 self.assertTrue(bug1_url in browser.contents)
757@@ -228,3 +312,127 @@
758 self.assertEqual(with_1_private_bug, with_3_private_bugs,
759 "different query count: \n%s\n******************\n%s\n" % (
760 '\n'.join(with_1_queries), '\n'.join(with_3_queries)))
761+
762+
763+class TestProjectGroupMilestoneIndexQueryCount(TestQueryCountBase):
764+
765+ layer = DatabaseFunctionalLayer
766+
767+ def setUp(self):
768+ super(TestProjectGroupMilestoneIndexQueryCount, self).setUp()
769+ # Increase cache size so that the query counts aren't affected
770+ # by objects being removed from the cache early.
771+ config.push('storm-cache', dedent('''
772+ [launchpad]
773+ storm_cache_size: 1000
774+ '''))
775+ self.addCleanup(config.pop, 'storm-cache')
776+ self.owner = self.factory.makePerson(name='product-owner')
777+ self.project_group = self.factory.makeProject(owner=self.owner)
778+ login_person(self.owner)
779+ self.milestone_name = 'foo'
780+ # A ProjectGroup milestone doesn't exist unless one of its
781+ # Projects has a milestone of that name.
782+ product = self.factory.makeProduct(
783+ owner=self.owner, project=self.project_group)
784+ self.product_milestone = self.factory.makeMilestone(
785+ productseries=product.development_focus,
786+ name=self.milestone_name)
787+ self.milestone = self.project_group.getMilestone(
788+ self.milestone_name)
789+
790+ def add_bug(self, count):
791+ login_person(self.owner)
792+ for i in range(count):
793+ bug = self.factory.makeBug(product=self.product_milestone.product)
794+ bug.bugtasks[0].transitionToMilestone(
795+ self.product_milestone, self.owner)
796+ # This is necessary to test precaching of assignees.
797+ bug.bugtasks[0].transitionToAssignee(
798+ self.factory.makePerson())
799+ logout()
800+
801+ def test_bugtasks_queries(self):
802+ # The view.bugtasks attribute will make five queries:
803+ # 1. For each project in the group load all the dev focus series ids.
804+ # 2. Load bugtasks and bugs.
805+ # 3. Load assignees (Person, Account, and EmailAddress).
806+ # 4. Load links to specifications.
807+ # 5. Load links to branches.
808+ bugtask_count = 10
809+ self.assert_bugtasks_query_count(
810+ self.milestone, bugtask_count, query_limit=6)
811+
812+ def test_milestone_eager_loading(self):
813+ # Verify that the number of queries does not increase with more
814+ # bugs with different assignees as long as the number of
815+ # projects doesn't increase.
816+ query_limit = 37
817+ self.add_bug(1)
818+ self.assert_milestone_page_query_count(
819+ self.milestone, query_limit=query_limit)
820+ self.add_bug(10)
821+ self.assert_milestone_page_query_count(
822+ self.milestone, query_limit=query_limit)
823+
824+
825+class TestDistributionMilestoneIndexQueryCount(TestQueryCountBase):
826+
827+ layer = DatabaseFunctionalLayer
828+
829+ def setUp(self):
830+ super(TestDistributionMilestoneIndexQueryCount, self).setUp()
831+ # Increase cache size so that the query counts aren't affected
832+ # by objects being removed from the cache early.
833+ config.push('storm-cache', dedent('''
834+ [launchpad]
835+ storm_cache_size: 1000
836+ '''))
837+ self.addCleanup(config.pop, 'storm-cache')
838+ self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
839+ self.owner = self.factory.makePerson(name='test-owner')
840+ login_team(self.ubuntu.owner)
841+ self.ubuntu.owner = self.owner
842+ self.sourcepackagename = self.factory.makeSourcePackageName(
843+ 'foo-package')
844+ login_person(self.owner)
845+ self.milestone = self.factory.makeMilestone(
846+ distribution=self.ubuntu)
847+
848+ def add_bug(self, count):
849+ login_person(self.owner)
850+ for i in range(count):
851+ bug = self.factory.makeBug(distribution=self.ubuntu)
852+ distrosourcepackage = self.factory.makeDistributionSourcePackage(
853+ distribution=self.ubuntu)
854+ bug.bugtasks[0].transitionToTarget(distrosourcepackage)
855+ bug.bugtasks[0].transitionToMilestone(
856+ self.milestone, self.owner)
857+ # This is necessary to test precaching of assignees.
858+ bug.bugtasks[0].transitionToAssignee(
859+ self.factory.makePerson())
860+ logout()
861+
862+ def test_bugtasks_queries(self):
863+ # The view.bugtasks attribute will make seven queries:
864+ # 1. Load ubuntu.currentseries.
865+ # 2. Check if the user is in the admin team.
866+ # 3. Check if the user is in the owner of the admin team.
867+ # 4. Load bugtasks and bugs.
868+ # 5. Load assignees (Person, Account, and EmailAddress).
869+ # 6. Load links to specifications.
870+ # 7. Load links to branches.
871+ bugtask_count = 10
872+ self.assert_bugtasks_query_count(
873+ self.milestone, bugtask_count, query_limit=8)
874+
875+ def test_milestone_eager_loading(self):
876+ # Verify that the number of queries does not increase with more
877+ # bugs with different assignees.
878+ query_limit = 32
879+ self.add_bug(3)
880+ self.assert_milestone_page_query_count(
881+ self.milestone, query_limit=query_limit)
882+ self.add_bug(10)
883+ self.assert_milestone_page_query_count(
884+ self.milestone, query_limit=query_limit)
885
886=== modified file 'lib/lp/registry/interfaces/person.py'
887--- lib/lp/registry/interfaces/person.py 2010-12-10 20:54:12 +0000
888+++ lib/lp/registry/interfaces/person.py 2010-12-15 16:58:33 +0000
889@@ -2151,6 +2151,24 @@
890 def cacheBrandingForPeople(people):
891 """Prefetch Librarian aliases and content for personal images."""
892
893+ def getPrecachedPersonsFromIDs(
894+ person_ids, need_karma=False, need_ubuntu_coc=False,
895+ need_location=False, need_archive=False,
896+ need_preferred_email=False, need_validity=False):
897+ """Lookup person objects from ids with optional precaching.
898+
899+ :param person_ids: List of person ids.
900+ :param need_karma: The karma attribute will be cached.
901+ :param need_ubuntu_coc: The is_ubuntu_coc_signer attribute will be
902+ cached.
903+ :param need_location: The location attribute will be cached.
904+ :param need_archive: The archive attribute will be cached.
905+ :param need_preferred_email: The preferred email attribute will be
906+ cached.
907+ :param need_validity: The is_valid attribute will be cached.
908+ """
909+
910+
911
912 class IRequestPeopleMerge(Interface):
913 """This schema is used only because we want a very specific vocabulary."""
914
915=== modified file 'lib/lp/registry/model/person.py'
916--- lib/lp/registry/model/person.py 2010-12-10 14:58:31 +0000
917+++ lib/lp/registry/model/person.py 2010-12-15 16:58:33 +0000
918@@ -1672,7 +1672,6 @@
919 # The difference between the two is that
920 # getMembersWithPreferredEmails includes self, which is arguably
921 # wrong, but perhaps deliberate.
922- store = Store.of(self)
923 origin = [
924 Person,
925 Join(TeamParticipation, TeamParticipation.person == Person.id),
926@@ -1682,99 +1681,12 @@
927 TeamParticipation.team == self.id,
928 # But not the team itself.
929 TeamParticipation.person != self.id)
930- columns = [Person]
931- decorators = []
932- if need_karma:
933- # New people have no karmatotalcache rows.
934- origin.append(
935- LeftJoin(KarmaTotalCache,
936- KarmaTotalCache.person == Person.id))
937- columns.append(KarmaTotalCache)
938- if need_ubuntu_coc:
939- columns.append(Alias(Exists(Select(SignedCodeOfConduct,
940- And(Person._is_ubuntu_coc_signer_condition(),
941- SignedCodeOfConduct.ownerID == Person.id))),
942- name='is_ubuntu_coc_signer'))
943- if need_location:
944- # New people have no location rows
945- origin.append(
946- LeftJoin(PersonLocation,
947- PersonLocation.person == Person.id))
948- columns.append(PersonLocation)
949- if need_archive:
950- # Not everyone has PPA's
951- # It would be nice to cleanly expose the soyuz rules for this to
952- # avoid duplicating the relationships.
953- origin.append(
954- LeftJoin(Archive, Archive.owner == Person.id))
955- columns.append(Archive)
956- conditions = And(conditions,
957- Or(Archive.id == None, And(
958- Archive.id == Select(Min(Archive.id),
959- Archive.owner == Person.id, Archive),
960- Archive.purpose == ArchivePurpose.PPA)))
961- # checking validity requires having a preferred email.
962- if need_preferred_email and not need_validity:
963- # Teams don't have email, so a left join
964- origin.append(
965- LeftJoin(EmailAddress, EmailAddress.person == Person.id))
966- columns.append(EmailAddress)
967- conditions = And(conditions,
968- Or(EmailAddress.status == None,
969- EmailAddress.status == EmailAddressStatus.PREFERRED))
970- if need_validity:
971- valid_stuff = Person._validity_queries()
972- origin.extend(valid_stuff["joins"])
973- columns.extend(valid_stuff["tables"])
974- decorators.extend(valid_stuff["decorators"])
975- if len(columns) == 1:
976- columns = columns[0]
977- # Return a simple ResultSet
978- return store.using(*origin).find(columns, conditions)
979- # Adapt the result into a cached Person.
980- columns = tuple(columns)
981- raw_result = store.using(*origin).find(columns, conditions)
982-
983- def prepopulate_person(row):
984- result = row[0]
985- cache = get_property_cache(result)
986- index = 1
987- #-- karma caching
988- if need_karma:
989- karma = row[index]
990- index += 1
991- if karma is None:
992- karma_total = 0
993- else:
994- karma_total = karma.karma_total
995- cache.karma = karma_total
996- #-- ubuntu code of conduct signer status caching.
997- if need_ubuntu_coc:
998- signed = row[index]
999- index += 1
1000- cache.is_ubuntu_coc_signer = signed
1001- #-- location caching
1002- if need_location:
1003- location = row[index]
1004- index += 1
1005- cache.location = location
1006- #-- archive caching
1007- if need_archive:
1008- archive = row[index]
1009- index += 1
1010- cache.archive = archive
1011- #-- preferred email caching
1012- if need_preferred_email and not need_validity:
1013- email = row[index]
1014- index += 1
1015- cache.preferredemail = email
1016- for decorator in decorators:
1017- column = row[index]
1018- index += 1
1019- decorator(result, column)
1020- return result
1021- return DecoratedResultSet(raw_result,
1022- result_decorator=prepopulate_person)
1023+ return PersonSet()._getPrecachedPersons(
1024+ origin, conditions, store=Store.of(self),
1025+ need_karma=need_karma, need_ubuntu_coc=need_ubuntu_coc,
1026+ need_location=need_location, need_archive=need_archive,
1027+ need_preferred_email=need_preferred_email,
1028+ need_validity=need_validity)
1029
1030 def _getMembersWithPreferredEmails(self):
1031 """Helper method for public getMembersWithPreferredEmails.
1032@@ -4129,6 +4041,137 @@
1033 list(LibraryFileAlias.select("LibraryFileAlias.id IN %s"
1034 % sqlvalues(aliases), prejoins=["content"]))
1035
1036+ def getPrecachedPersonsFromIDs(
1037+ self, person_ids, need_karma=False, need_ubuntu_coc=False,
1038+ need_location=False, need_archive=False,
1039+ need_preferred_email=False, need_validity=False):
1040+ """See `IPersonSet`."""
1041+ origin = [Person]
1042+ conditions = [
1043+ Person.id.is_in(person_ids)]
1044+ return self._getPrecachedPersons(
1045+ origin, conditions,
1046+ need_karma=need_karma, need_ubuntu_coc=need_ubuntu_coc,
1047+ need_location=need_location, need_archive=need_archive,
1048+ need_preferred_email=need_preferred_email,
1049+ need_validity=need_validity)
1050+
1051+ def _getPrecachedPersons(
1052+ self, origin, conditions, store=None,
1053+ need_karma=False, need_ubuntu_coc=False,
1054+ need_location=False, need_archive=False, need_preferred_email=False,
1055+ need_validity=False):
1056+ """Lookup all members of the team with optional precaching.
1057+
1058+ :param store: Provide ability to specify the store.
1059+ :param origin: List of storm tables and joins. This list will be
1060+ appended to. The Person table is required.
1061+ :param conditions: Storm conditions for tables in origin.
1062+ :param need_karma: The karma attribute will be cached.
1063+ :param need_ubuntu_coc: The is_ubuntu_coc_signer attribute will be
1064+ cached.
1065+ :param need_location: The location attribute will be cached.
1066+ :param need_archive: The archive attribute will be cached.
1067+ :param need_preferred_email: The preferred email attribute will be
1068+ cached.
1069+ :param need_validity: The is_valid attribute will be cached.
1070+ """
1071+ if store is None:
1072+ store = IStore(Person)
1073+ columns = [Person]
1074+ decorators = []
1075+ if need_karma:
1076+ # New people have no karmatotalcache rows.
1077+ origin.append(
1078+ LeftJoin(KarmaTotalCache,
1079+ KarmaTotalCache.person == Person.id))
1080+ columns.append(KarmaTotalCache)
1081+ if need_ubuntu_coc:
1082+ columns.append(Alias(Exists(Select(SignedCodeOfConduct,
1083+ And(Person._is_ubuntu_coc_signer_condition(),
1084+ SignedCodeOfConduct.ownerID == Person.id))),
1085+ name='is_ubuntu_coc_signer'))
1086+ if need_location:
1087+ # New people have no location rows
1088+ origin.append(
1089+ LeftJoin(PersonLocation,
1090+ PersonLocation.person == Person.id))
1091+ columns.append(PersonLocation)
1092+ if need_archive:
1093+ # Not everyone has PPA's
1094+ # It would be nice to cleanly expose the soyuz rules for this to
1095+ # avoid duplicating the relationships.
1096+ origin.append(
1097+ LeftJoin(Archive, Archive.owner == Person.id))
1098+ columns.append(Archive)
1099+ conditions = And(conditions,
1100+ Or(Archive.id == None, And(
1101+ Archive.id == Select(Min(Archive.id),
1102+ Archive.owner == Person.id, Archive),
1103+ Archive.purpose == ArchivePurpose.PPA)))
1104+ # checking validity requires having a preferred email.
1105+ if need_preferred_email and not need_validity:
1106+ # Teams don't have email, so a left join
1107+ origin.append(
1108+ LeftJoin(EmailAddress, EmailAddress.person == Person.id))
1109+ columns.append(EmailAddress)
1110+ conditions = And(conditions,
1111+ Or(EmailAddress.status == None,
1112+ EmailAddress.status == EmailAddressStatus.PREFERRED))
1113+ if need_validity:
1114+ valid_stuff = Person._validity_queries()
1115+ origin.extend(valid_stuff["joins"])
1116+ columns.extend(valid_stuff["tables"])
1117+ decorators.extend(valid_stuff["decorators"])
1118+ if len(columns) == 1:
1119+ columns = columns[0]
1120+ # Return a simple ResultSet
1121+ return store.using(*origin).find(columns, conditions)
1122+ # Adapt the result into a cached Person.
1123+ columns = tuple(columns)
1124+ raw_result = store.using(*origin).find(columns, conditions)
1125+
1126+ def prepopulate_person(row):
1127+ result = row[0]
1128+ cache = get_property_cache(result)
1129+ index = 1
1130+ #-- karma caching
1131+ if need_karma:
1132+ karma = row[index]
1133+ index += 1
1134+ if karma is None:
1135+ karma_total = 0
1136+ else:
1137+ karma_total = karma.karma_total
1138+ cache.karma = karma_total
1139+ #-- ubuntu code of conduct signer status caching.
1140+ if need_ubuntu_coc:
1141+ signed = row[index]
1142+ index += 1
1143+ cache.is_ubuntu_coc_signer = signed
1144+ #-- location caching
1145+ if need_location:
1146+ location = row[index]
1147+ index += 1
1148+ cache.location = location
1149+ #-- archive caching
1150+ if need_archive:
1151+ archive = row[index]
1152+ index += 1
1153+ cache.archive = archive
1154+ #-- preferred email caching
1155+ if need_preferred_email and not need_validity:
1156+ email = row[index]
1157+ index += 1
1158+ cache.preferredemail = email
1159+ for decorator in decorators:
1160+ column = row[index]
1161+ index += 1
1162+ decorator(result, column)
1163+ return result
1164+ return DecoratedResultSet(raw_result,
1165+ result_decorator=prepopulate_person)
1166+
1167
1168 # Provide a storm alias from Person to Owner. This is useful in queries on
1169 # objects that have more than just an owner associated with them.
1170
1171=== modified file 'lib/lp/registry/tests/test_person.py'
1172--- lib/lp/registry/tests/test_person.py 2010-12-10 07:52:21 +0000
1173+++ lib/lp/registry/tests/test_person.py 2010-12-15 16:58:33 +0000
1174@@ -67,6 +67,7 @@
1175 login_person,
1176 logout,
1177 person_logged_in,
1178+ StormStatementRecorder,
1179 TestCase,
1180 TestCaseWithFactory,
1181 )
1182@@ -428,12 +429,12 @@
1183 self.assertEqual('(\\u0170-tester)>', displayname)
1184
1185
1186-class TestPersonSet(TestCase):
1187+class TestPersonSet(TestCaseWithFactory):
1188 """Test `IPersonSet`."""
1189 layer = DatabaseFunctionalLayer
1190
1191 def setUp(self):
1192- TestCase.setUp(self)
1193+ super(TestPersonSet, self).setUp()
1194 login(ANONYMOUS)
1195 self.addCleanup(logout)
1196 self.person_set = getUtility(IPersonSet)
1197@@ -457,6 +458,31 @@
1198 "PersonSet.getByEmail() should ignore case and whitespace.")
1199 self.assertEqual(person1, person2)
1200
1201+ def test_getPrecachedPersonsFromIDs(self):
1202+ # The getPrecachedPersonsFromIDs() method should only make one
1203+ # query to load all the extraneous data. Accessing the
1204+ # attributes should then cause zero queries.
1205+ person_ids = [
1206+ self.factory.makePerson().id
1207+ for i in range(3)]
1208+
1209+ with StormStatementRecorder() as recorder:
1210+ persons = list(self.person_set.getPrecachedPersonsFromIDs(
1211+ person_ids, need_karma=True, need_ubuntu_coc=True,
1212+ need_location=True, need_archive=True,
1213+ need_preferred_email=True, need_validity=True))
1214+ self.assertThat(recorder, HasQueryCount(LessThan(2)))
1215+
1216+ with StormStatementRecorder() as recorder:
1217+ for person in persons:
1218+ person.is_valid_person
1219+ person.karma
1220+ person.is_ubuntu_coc_signer
1221+ person.location
1222+ person.archive
1223+ person.preferredemail
1224+ self.assertThat(recorder, HasQueryCount(LessThan(1)))
1225+
1226
1227 class KarmaTestMixin:
1228 """Helper methods for setting karma."""