← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/launchpad/subscribe-to-tag-bug-151129-2 into lp:launchpad/devel

 

Gavin Panella has proposed merging lp:~allenap/launchpad/subscribe-to-tag-bug-151129-2 into lp:launchpad/devel with lp:~allenap/launchpad/subscribe-to-tag-bug-151129 as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)


This changes the way subscription filters are found. Instead of some
left joins and some array gymnastics there is a convenience class,
BugFilterSetBuilder, that combines sets of filters to figure out which
match. From that it can figure out which subscriptions match.

-- 
https://code.launchpad.net/~allenap/launchpad/subscribe-to-tag-bug-151129-2/+merge/41186
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/launchpad/subscribe-to-tag-bug-151129-2 into lp:launchpad/devel.
=== modified file 'lib/lp/registry/model/structuralsubscription.py'
--- lib/lp/registry/model/structuralsubscription.py	2010-11-18 16:11:43 +0000
+++ lib/lp/registry/model/structuralsubscription.py	2010-11-18 16:11:51 +0000
@@ -8,9 +8,7 @@
 from sqlobject import ForeignKey
 from storm.expr import (
     And,
-    LeftJoin,
-    Or,
-    SQL,
+    In,
     )
 from storm.store import Store
 from zope.component import (
@@ -29,12 +27,6 @@
 from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
 from canonical.launchpad.interfaces.lpstorm import IStore
 from lp.bugs.model.bugsubscriptionfilter import BugSubscriptionFilter
-from lp.bugs.model.bugsubscriptionfilterimportance import (
-    BugSubscriptionFilterImportance,
-    )
-from lp.bugs.model.bugsubscriptionfilterstatus import (
-    BugSubscriptionFilterStatus,
-    )
 from lp.registry.enum import BugNotificationLevel
 from lp.registry.errors import (
     DeleteSubscriptionError,
@@ -472,93 +464,10 @@
 
     def getSubscriptionsForBugTask(self, bugtask, level):
         """See `IStructuralSubscriptionTarget`."""
-        origin = [
-            StructuralSubscription,
-            LeftJoin(
-                BugSubscriptionFilter,
-                BugSubscriptionFilter.structural_subscription_id == (
-                    StructuralSubscription.id)),
-            LeftJoin(
-                BugSubscriptionFilterStatus,
-                BugSubscriptionFilterStatus.filter_id == (
-                    BugSubscriptionFilter.id)),
-            LeftJoin(
-                BugSubscriptionFilterImportance,
-                BugSubscriptionFilterImportance.filter_id == (
-                    BugSubscriptionFilter.id)),
-            ]
-
-        # An ARRAY[] expression for the given bug's tags.
-        tags_array = "ARRAY[%s]::TEXT[]" % ",".join(
-            quote(tag) for tag in bugtask.bug.tags)
-
-        # The tags a subscription requests for inclusion.
-        tags_include_expr = (
-            "SELECT tag FROM BugSubscriptionFilterTag "
-            "WHERE filter = BugSubscriptionFilter.id AND include")
-        tags_include_array = "ARRAY(%s)" % tags_include_expr
-        tags_include_is_empty = SQL(
-            "ARRAY[]::TEXT[] = %s" % tags_include_array)
-
-        # The tags a subscription requests for exclusion.
-        tags_exclude_expr = (
-            "SELECT tag FROM BugSubscriptionFilterTag "
-            "WHERE filter = BugSubscriptionFilter.id AND NOT include")
-        tags_exclude_array = "ARRAY(%s)" % tags_exclude_expr
-        tags_exclude_is_empty = SQL(
-            "ARRAY[]::TEXT[] = %s" % tags_exclude_array)
-
-        # Choose the correct expression depending on the find_all_tags flag.
-        def tags_find_all_combinator(find_all_expr, find_any_expr):
-            return SQL(
-                "CASE WHEN BugSubscriptionFilter.find_all_tags "
-                "THEN (%s) ELSE (%s) END" % (find_all_expr, find_any_expr))
-
-        if len(bugtask.bug.tags) == 0:
-            tag_conditions = [
-                BugSubscriptionFilter.include_any_tags == False,
-                # The subscription's required tags must be an empty set.
-                tags_include_is_empty,
-                # The subscription's excluded tags can be anything so no
-                # condition is needed.
-                ]
-        else:
-            tag_conditions = [
-                BugSubscriptionFilter.exclude_any_tags == False,
-                # The bug's tags must contain the subscription's required tags
-                # if find_all_tags is set, else there must be an intersection.
-                Or(tags_include_is_empty,
-                   tags_find_all_combinator(
-                        "%s @> %s" % (tags_array, tags_include_array),
-                        "%s && %s" % (tags_array, tags_include_array))),
-                # The bug's tags must not contain the subscription's excluded
-                # tags if find_all_tags is set, else there must not be an
-                # intersection.
-                Or(tags_exclude_is_empty,
-                   tags_find_all_combinator(
-                        "NOT (%s @> %s)" % (tags_array, tags_exclude_array),
-                        "NOT (%s && %s)" % (tags_array, tags_exclude_array))),
-                ]
-
-        conditions = [
-            StructuralSubscription.bug_notification_level >= level,
-            Or(
-                # There's no filter or ...
-                BugSubscriptionFilter.id == None,
-                # There is a filter and ...
-                And(
-                    # There's no status filter, or there is a status filter
-                    # and and it matches.
-                    Or(BugSubscriptionFilterStatus.id == None,
-                       BugSubscriptionFilterStatus.status == bugtask.status),
-                    # There's no importance filter, or there is an importance
-                    # filter and it matches.
-                    Or(BugSubscriptionFilterImportance.id == None,
-                       BugSubscriptionFilterImportance.importance == (
-                            bugtask.importance)),
-                    # Any number of conditions relating to tags.
-                    *tag_conditions)),
-            ]
-
-        return Store.of(self.__helper.pillar).using(*origin).find(
-            StructuralSubscription, self.__helper.join, *conditions)
+        from lp.registry.model import structuralsubscriptionfilter
+        set_builder = structuralsubscriptionfilter.BugFilterSetBuilder(
+            bugtask, level, self.__helper.join)
+        return Store.of(self.__helper.pillar).find(
+            StructuralSubscription, In(
+                StructuralSubscription.id,
+                set_builder.subscriptions))

=== added file 'lib/lp/registry/model/structuralsubscriptionfilter.py'
--- lib/lp/registry/model/structuralsubscriptionfilter.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/model/structuralsubscriptionfilter.py	2010-11-18 16:11:51 +0000
@@ -0,0 +1,246 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+__all__ = [
+    'BugFilterSetBuilder',
+    ]
+
+from storm.expr import (
+    Alias,
+    And,
+    CompoundOper,
+    Except,
+    In,
+    Intersect,
+    LeftJoin,
+    NamedFunc,
+    Not,
+    Or,
+    Select,
+    SQL,
+    Union,
+    )
+
+from canonical.database.sqlbase import quote
+from lp.bugs.model.bugsubscriptionfilter import BugSubscriptionFilter
+from lp.bugs.model.bugsubscriptionfilterimportance import (
+    BugSubscriptionFilterImportance,
+    )
+from lp.bugs.model.bugsubscriptionfilterstatus import (
+    BugSubscriptionFilterStatus,
+    )
+from lp.bugs.model.bugsubscriptionfiltertag import BugSubscriptionFilterTag
+from lp.registry.model.structuralsubscription import StructuralSubscription
+
+
+class ArrayAgg(NamedFunc):
+    __slots__ = ()
+    name = "ARRAY_AGG"
+
+
+class ArrayContains(CompoundOper):
+    __slots__ = ()
+    oper = "@>"
+
+
+class BugFilterSetBuilder:
+    """A convenience class to build queries for getSubscriptionsForBugTask."""
+
+    def __init__(self, bugtask, level, join_condition):
+        self.status = bugtask.status
+        self.importance = bugtask.importance
+        # The list() gets around some weirdness with security proxies; Storm
+        # does not know how to compile an expression with a proxied list.
+        self.tags = list(bugtask.bug.tags)
+        # Set up common conditions.
+        self.base_conditions = And(
+            StructuralSubscription.bug_notification_level >= level,
+            join_condition)
+        # Set up common filter conditions.
+        if len(self.tags) == 0:
+            self.filter_conditions = And(
+                BugSubscriptionFilter.include_any_tags == False,
+                self.base_conditions)
+        else:
+            self.filter_conditions = And(
+                BugSubscriptionFilter.exclude_any_tags == False,
+                self.base_conditions)
+
+    @property
+    def subscriptions_without_filters(self):
+        """Subscriptions without filters."""
+        return Select(
+            StructuralSubscription.id,
+            tables=(
+                StructuralSubscription,
+                LeftJoin(
+                    BugSubscriptionFilter,
+                    BugSubscriptionFilter.structural_subscription_id == (
+                        StructuralSubscription.id))),
+            where=And(
+                BugSubscriptionFilter.id == None,
+                self.base_conditions))
+
+    def _filters_matching_x(self, join, where_condition, **extra):
+        # The expressions returned by this function are used in set (union,
+        # intersect, except) operations at the *filter* level. However, the
+        # interesting result of these set operations is the structural
+        # subscription, hence both columns are included in the expressions
+        # generated. Since a structural subscription can have zero or more
+        # filters, and a filter can never be associated with more than one
+        # subscription, the set operations are unaffected.
+        return Select(
+            columns=(
+                # Alias this column so it can be selected in
+                # subscriptions_matching.
+                Alias(
+                    BugSubscriptionFilter.structural_subscription_id,
+                    "structural_subscription_id"),
+                BugSubscriptionFilter.id),
+            tables=(
+                StructuralSubscription, BugSubscriptionFilter, join),
+            where=And(
+                BugSubscriptionFilter.structural_subscription_id == (
+                    StructuralSubscription.id),
+                self.filter_conditions,
+                where_condition),
+            **extra)
+
+    @property
+    def filters_matching_status(self):
+        """Filters with the given bugtask's status."""
+        join = LeftJoin(
+            BugSubscriptionFilterStatus,
+            BugSubscriptionFilterStatus.filter_id == (
+                BugSubscriptionFilter.id))
+        condition = Or(
+            BugSubscriptionFilterStatus.id == None,
+            BugSubscriptionFilterStatus.status == self.status)
+        return self._filters_matching_x(join, condition)
+
+    @property
+    def filters_matching_importance(self):
+        """Filters with the given bugtask's importance."""
+        join = LeftJoin(
+            BugSubscriptionFilterImportance,
+            BugSubscriptionFilterImportance.filter_id == (
+                BugSubscriptionFilter.id))
+        condition = Or(
+            BugSubscriptionFilterImportance.id == None,
+            BugSubscriptionFilterImportance.importance == self.importance)
+        return self._filters_matching_x(join, condition)
+
+    @property
+    def filters_without_include_tags(self):
+        """Filters with no tags required."""
+        join = LeftJoin(
+            BugSubscriptionFilterTag,
+            And(BugSubscriptionFilterTag.filter_id == (
+                    BugSubscriptionFilter.id),
+                BugSubscriptionFilterTag.include))
+        return self._filters_matching_x(
+            join, BugSubscriptionFilterTag.id == None)
+
+    @property
+    def filters_matching_any_include_tags(self):
+        """Filters including any of the bug's tags."""
+        condition = And(
+            BugSubscriptionFilterTag.filter_id == (
+                BugSubscriptionFilter.id),
+            BugSubscriptionFilterTag.include,
+            Not(BugSubscriptionFilter.find_all_tags),
+            In(BugSubscriptionFilterTag.tag, self.tags))
+        return self._filters_matching_x(
+            BugSubscriptionFilterTag, condition)
+
+    @property
+    def filters_matching_any_exclude_tags(self):
+        """Filters excluding any of the bug's tags."""
+        condition = And(
+            BugSubscriptionFilterTag.filter_id == (
+                BugSubscriptionFilter.id),
+            Not(BugSubscriptionFilterTag.include),
+            Not(BugSubscriptionFilter.find_all_tags),
+            In(BugSubscriptionFilterTag.tag, self.tags))
+        return self._filters_matching_x(
+            BugSubscriptionFilterTag, condition)
+
+    def _filters_matching_all_x_tags(self, where_condition):
+        tags_array = "ARRAY[%s]::TEXT[]" % ",".join(
+            quote(tag) for tag in self.tags)
+        return self._filters_matching_x(
+            BugSubscriptionFilterTag,
+            And(
+                BugSubscriptionFilterTag.filter_id == (
+                    BugSubscriptionFilter.id),
+                BugSubscriptionFilter.find_all_tags,
+                self.filter_conditions,
+                where_condition),
+            group_by=(
+                BugSubscriptionFilter.structural_subscription_id,
+                BugSubscriptionFilter.id),
+            having=ArrayContains(
+                SQL(tags_array), ArrayAgg(
+                    BugSubscriptionFilterTag.tag)))
+
+    @property
+    def filters_matching_all_include_tags(self):
+        """Filters including the bug's tags."""
+        return self._filters_matching_all_x_tags(
+            BugSubscriptionFilterTag.include)
+
+    @property
+    def filters_matching_all_exclude_tags(self):
+        """Filters excluding the bug's tags."""
+        return self._filters_matching_all_x_tags(
+            Not(BugSubscriptionFilterTag.include))
+
+    @property
+    def filters_matching_include_tags(self):
+        """Filters with tag filters including the bug."""
+        return Union(
+            self.filters_matching_any_include_tags,
+            self.filters_matching_all_include_tags)
+
+    @property
+    def filters_matching_exclude_tags(self):
+        """Filters with tag filters excluding the bug."""
+        return Union(
+            self.filters_matching_any_exclude_tags,
+            self.filters_matching_all_exclude_tags)
+
+    @property
+    def filters_matching_tags(self):
+        """Filters with tag filters matching the bug."""
+        if len(self.tags) == 0:
+            # The filter's required tags must be an empty set. The filter's
+            # excluded tags can be anything so no condition is needed.
+            return self.filters_without_include_tags
+        else:
+            return Except(
+                Union(self.filters_without_include_tags,
+                      self.filters_matching_include_tags),
+                self.filters_matching_exclude_tags)
+
+    @property
+    def filters_matching(self):
+        """Filters matching the bug."""
+        return Intersect(
+            self.filters_matching_status,
+            self.filters_matching_importance,
+            self.filters_matching_tags)
+
+    @property
+    def subscriptions_with_matching_filters(self):
+        """Subscriptions with one or more filters matching the bug."""
+        return Select(
+            # I don't know of a more Storm-like way of doing this.
+            SQL("filters_matching.structural_subscription_id"),
+            tables=Alias(self.filters_matching, "filters_matching"))
+
+    @property
+    def subscriptions(self):
+        return Union(
+            self.subscriptions_without_filters,
+            self.subscriptions_with_matching_filters)

=== modified file 'lib/lp/registry/tests/test_structuralsubscriptiontarget.py'
--- lib/lp/registry/tests/test_structuralsubscriptiontarget.py	2010-11-18 16:11:43 +0000
+++ lib/lp/registry/tests/test_structuralsubscriptiontarget.py	2010-11-18 16:11:51 +0000
@@ -67,7 +67,7 @@
 
 
 class RestrictedStructuralSubscription(StructuralSubscriptionTestBase):
-    # Tests suitable for a target that restricts structural subscriptions.
+    """Tests suitable for a target that restricts structural subscriptions."""
 
     def test_target_implements_structural_subscription_target(self):
         self.assertTrue(verifyObject(IStructuralSubscriptionTarget,
@@ -128,8 +128,10 @@
 
 
 class UnrestrictedStructuralSubscription(RestrictedStructuralSubscription):
-    # Tests suitable for a target that does not restrict structural
-    # subscriptions.
+    """
+    Tests suitable for a target that does not restrict structural
+    subscriptions.
+    """
 
     def test_structural_subscription_by_ordinary_user(self):
         # ordinary users can subscribe themselves
@@ -409,6 +411,35 @@
         self.bug.tags = ["bar", "baz"]
         self.assertSubscriptions([self.subscription])
 
+    def test_getSubscriptionsForBugTask_any_filter_is_a_match(self):
+        # If a subscription has multiple filters, the subscription is selected
+        # when any filter is found to match. Put another way, the filters are
+        # ORed together.
+        subscription_filter1 = self.subscription.newBugFilter()
+        subscription_filter1.statuses = [BugTaskStatus.CONFIRMED]
+        subscription_filter2 = self.subscription.newBugFilter()
+        subscription_filter2.tags = [u"foo"]
+
+        # With the filter the subscription is not found.
+        self.assertSubscriptions([])
+
+        # If the bugtask is adjusted to match the criteria of the first filter
+        # but not those of the second, the subscription is found.
+        self.bugtask.transitionToStatus(
+            BugTaskStatus.CONFIRMED, self.ordinary_subscriber)
+        self.assertSubscriptions([self.subscription])
+
+        # If the filter is adjusted to also match the criteria of the second
+        # filter, the subscription is still found.
+        self.bugtask.bug.tags = [u"foo"]
+        self.assertSubscriptions([self.subscription])
+
+        # If the bugtask is adjusted to no longer match the criteria of the
+        # first filter, the subscription is still found.
+        self.bugtask.transitionToStatus(
+            BugTaskStatus.INPROGRESS, self.ordinary_subscriber)
+        self.assertSubscriptions([self.subscription])
+
 
 class TestStructuralSubscriptionForDistro(
     RestrictedStructuralSubscription, TestCaseWithFactory):