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

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 11972
Proposed branch: lp:~allenap/launchpad/subscribe-to-tag-bug-151129-2
Merge into: lp:launchpad
Prerequisite: lp:~allenap/launchpad/subscribe-to-tag-bug-151129
Diff against target: 432 lines (+284/-92)
2 files modified
lib/lp/registry/model/structuralsubscription.py (+250/-89)
lib/lp/registry/tests/test_structuralsubscriptiontarget.py (+34/-3)
To merge this branch: bzr merge lp:~allenap/launchpad/subscribe-to-tag-bug-151129-2
Reviewer Review Type Date Requested Status
j.c.sackett (community) code* Approve
Abel Deuring (community) code Approve
Review via email: mp+41186@code.launchpad.net

Commit message

[r=adeuring,jcsackett][ui=none][bug=151129] Change the approach to finding structural subscriptions matching a bugtask.

Description of the change

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.

To post a comment you must log in.
Revision history for this message
j.c.sackett (jcsackett) wrote :
Download full text (17.4 KiB)

Gavin--

This is a really nice branch! I like cutting out all of that logic into the utility.

I am a little weirded out by it living alone in models; the logic is a file in models should have a model in it--this one is just utilities for another model file. Since it doesn't have use outside of structuralsubscription.py, I think it might be better added to that file. I don't think that hurts the readability of that file, since the logic is still nicely contained in the class you created.

Additional comments are in the diff below.

> === 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
> 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...

review: Needs Information (code*)
Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (3.5 KiB)

Thanks for the excellent review :)

> I am a little weirded out by it living alone in models; the logic is a file in
> models should have a model in it--this one is just utilities for another model
> file. Since it doesn't have use outside of structuralsubscription.py, I think
> it might be better added to that file. I don't think that hurts the
> readability of that file, since the logic is still nicely contained in the
> class you created.

Yeah, fair point. I moved it out because it helped me concentrate
while working on it, but it was a lot messier then. I've moved it
back.

...
> > + # 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)
> > +
>
> A quick comment here explaining the logic of setting up exclude_any vs
> include_any would be good.

Done.

...
> > + 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)
> > +
>
> I would prefer some sort of docstring for this in place of in addition to the
> comment block at the top.

Done.

...
> > + 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=(
> > + ...

Read more...

Revision history for this message
Abel Deuring (adeuring) wrote :

A very nice branch and a very nice review!

Just one more nitpick: Gavin, could you add a doc string to BugFilterSetBiulder.__init__(), and especially :param ...: lines? What bugtask and level means is obvious to me, but I had to poke around a bit to figure out where join_codition comes from.

review: Approve (code)
Revision history for this message
j.c.sackett (jcsackett) wrote :

Gavin--

I like those changes. Thanks!

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/registry/model/structuralsubscription.py'
2--- lib/lp/registry/model/structuralsubscription.py 2010-11-24 09:42:16 +0000
3+++ lib/lp/registry/model/structuralsubscription.py 2010-11-24 09:42:18 +0000
4@@ -2,15 +2,26 @@
5 # GNU Affero General Public License version 3 (see the file LICENSE).
6
7 __metaclass__ = type
8-__all__ = ['StructuralSubscription',
9- 'StructuralSubscriptionTargetMixin']
10+__all__ = [
11+ 'StructuralSubscription',
12+ 'StructuralSubscriptionTargetMixin',
13+ ]
14
15 from sqlobject import ForeignKey
16 from storm.expr import (
17+ Alias,
18 And,
19+ CompoundOper,
20+ Except,
21+ In,
22+ Intersect,
23 LeftJoin,
24+ NamedFunc,
25+ Not,
26 Or,
27+ Select,
28 SQL,
29+ Union,
30 )
31 from storm.store import Store
32 from zope.component import (
33@@ -35,6 +46,7 @@
34 from lp.bugs.model.bugsubscriptionfilterstatus import (
35 BugSubscriptionFilterStatus,
36 )
37+from lp.bugs.model.bugsubscriptionfiltertag import BugSubscriptionFilterTag
38 from lp.registry.enum import BugNotificationLevel
39 from lp.registry.errors import (
40 DeleteSubscriptionError,
41@@ -472,93 +484,242 @@
42
43 def getSubscriptionsForBugTask(self, bugtask, level):
44 """See `IStructuralSubscriptionTarget`."""
45- origin = [
46- StructuralSubscription,
47- LeftJoin(
48- BugSubscriptionFilter,
49+ set_builder = BugFilterSetBuilder(
50+ bugtask, level, self.__helper.join)
51+ return Store.of(self.__helper.pillar).find(
52+ StructuralSubscription, In(
53+ StructuralSubscription.id,
54+ set_builder.subscriptions))
55+
56+
57+class ArrayAgg(NamedFunc):
58+ __slots__ = ()
59+ name = "ARRAY_AGG"
60+
61+
62+class ArrayContains(CompoundOper):
63+ __slots__ = ()
64+ oper = "@>"
65+
66+
67+class BugFilterSetBuilder:
68+ """A convenience class to build queries for getSubscriptionsForBugTask."""
69+
70+ def __init__(self, bugtask, level, join_condition):
71+ """Initialize a new set builder for bug filters.
72+
73+ :param bugtask: The `IBugTask` to match against.
74+ :param level: A member of `BugNotificationLevel`.
75+ :param join_condition: A condition for selecting structural
76+ subscriptions. Generally this should limit the subscriptions to a
77+ particular target (i.e. project or distribution).
78+ """
79+ self.status = bugtask.status
80+ self.importance = bugtask.importance
81+ # The list() gets around some weirdness with security proxies; Storm
82+ # does not know how to compile an expression with a proxied list.
83+ self.tags = list(bugtask.bug.tags)
84+ # Set up common conditions.
85+ self.base_conditions = And(
86+ StructuralSubscription.bug_notification_level >= level,
87+ join_condition)
88+ # Set up common filter conditions.
89+ if len(self.tags) == 0:
90+ self.filter_conditions = And(
91+ # When the bug has no tags, filters with include_any_tags set
92+ # can never match.
93+ Not(BugSubscriptionFilter.include_any_tags),
94+ self.base_conditions)
95+ else:
96+ self.filter_conditions = And(
97+ # When the bug has tags, filters with exclude_any_tags set can
98+ # never match.
99+ Not(BugSubscriptionFilter.exclude_any_tags),
100+ self.base_conditions)
101+
102+ @property
103+ def subscriptions_without_filters(self):
104+ """Subscriptions without filters."""
105+ return Select(
106+ StructuralSubscription.id,
107+ tables=(
108+ StructuralSubscription,
109+ LeftJoin(
110+ BugSubscriptionFilter,
111+ BugSubscriptionFilter.structural_subscription_id == (
112+ StructuralSubscription.id))),
113+ where=And(
114+ BugSubscriptionFilter.id == None,
115+ self.base_conditions))
116+
117+ def _filters_matching_x(self, join, where_condition, **extra):
118+ """Return an expression yielding `(subscription_id, filter_id)` rows.
119+
120+ The expressions returned by this function are used in set (union,
121+ intersect, except) operations at the *filter* level. However, the
122+ interesting result of these set operations is the structural
123+ subscription, hence both columns are included in the expressions
124+ generated. Since a structural subscription can have zero or more
125+ filters, and a filter can never be associated with more than one
126+ subscription, the set operations are unaffected.
127+ """
128+ return Select(
129+ columns=(
130+ # Alias this column so it can be selected in
131+ # subscriptions_matching.
132+ Alias(
133+ BugSubscriptionFilter.structural_subscription_id,
134+ "structural_subscription_id"),
135+ BugSubscriptionFilter.id),
136+ tables=(
137+ StructuralSubscription, BugSubscriptionFilter, join),
138+ where=And(
139 BugSubscriptionFilter.structural_subscription_id == (
140- StructuralSubscription.id)),
141- LeftJoin(
142- BugSubscriptionFilterStatus,
143- BugSubscriptionFilterStatus.filter_id == (
144- BugSubscriptionFilter.id)),
145- LeftJoin(
146- BugSubscriptionFilterImportance,
147- BugSubscriptionFilterImportance.filter_id == (
148- BugSubscriptionFilter.id)),
149- ]
150-
151- # An ARRAY[] expression for the given bug's tags.
152+ StructuralSubscription.id),
153+ self.filter_conditions,
154+ where_condition),
155+ **extra)
156+
157+ @property
158+ def filters_matching_status(self):
159+ """Filters with the given bugtask's status."""
160+ join = LeftJoin(
161+ BugSubscriptionFilterStatus,
162+ BugSubscriptionFilterStatus.filter_id == (
163+ BugSubscriptionFilter.id))
164+ condition = Or(
165+ BugSubscriptionFilterStatus.id == None,
166+ BugSubscriptionFilterStatus.status == self.status)
167+ return self._filters_matching_x(join, condition)
168+
169+ @property
170+ def filters_matching_importance(self):
171+ """Filters with the given bugtask's importance."""
172+ join = LeftJoin(
173+ BugSubscriptionFilterImportance,
174+ BugSubscriptionFilterImportance.filter_id == (
175+ BugSubscriptionFilter.id))
176+ condition = Or(
177+ BugSubscriptionFilterImportance.id == None,
178+ BugSubscriptionFilterImportance.importance == self.importance)
179+ return self._filters_matching_x(join, condition)
180+
181+ @property
182+ def filters_without_include_tags(self):
183+ """Filters with no tags required."""
184+ join = LeftJoin(
185+ BugSubscriptionFilterTag,
186+ And(BugSubscriptionFilterTag.filter_id == (
187+ BugSubscriptionFilter.id),
188+ BugSubscriptionFilterTag.include))
189+ return self._filters_matching_x(
190+ join, BugSubscriptionFilterTag.id == None)
191+
192+ @property
193+ def filters_matching_any_include_tags(self):
194+ """Filters including any of the bug's tags."""
195+ condition = And(
196+ BugSubscriptionFilterTag.filter_id == (
197+ BugSubscriptionFilter.id),
198+ BugSubscriptionFilterTag.include,
199+ Not(BugSubscriptionFilter.find_all_tags),
200+ In(BugSubscriptionFilterTag.tag, self.tags))
201+ return self._filters_matching_x(
202+ BugSubscriptionFilterTag, condition)
203+
204+ @property
205+ def filters_matching_any_exclude_tags(self):
206+ """Filters excluding any of the bug's tags."""
207+ condition = And(
208+ BugSubscriptionFilterTag.filter_id == (
209+ BugSubscriptionFilter.id),
210+ Not(BugSubscriptionFilterTag.include),
211+ Not(BugSubscriptionFilter.find_all_tags),
212+ In(BugSubscriptionFilterTag.tag, self.tags))
213+ return self._filters_matching_x(
214+ BugSubscriptionFilterTag, condition)
215+
216+ def _filters_matching_all_x_tags(self, where_condition):
217+ """Return an expression yielding `(subscription_id, filter_id)` rows.
218+
219+ This joins to `BugSubscriptionFilterTag` and calls up to
220+ `_filters_matching_x`, and groups by filter. Conditions are added to
221+ ensure that all rows in each group are a subset of the bug's tags.
222+ """
223 tags_array = "ARRAY[%s]::TEXT[]" % ",".join(
224- quote(tag) for tag in bugtask.bug.tags)
225-
226- # The tags a subscription requests for inclusion.
227- tags_include_expr = (
228- "SELECT tag FROM BugSubscriptionFilterTag "
229- "WHERE filter = BugSubscriptionFilter.id AND include")
230- tags_include_array = "ARRAY(%s)" % tags_include_expr
231- tags_include_is_empty = SQL(
232- "ARRAY[]::TEXT[] = %s" % tags_include_array)
233-
234- # The tags a subscription requests for exclusion.
235- tags_exclude_expr = (
236- "SELECT tag FROM BugSubscriptionFilterTag "
237- "WHERE filter = BugSubscriptionFilter.id AND NOT include")
238- tags_exclude_array = "ARRAY(%s)" % tags_exclude_expr
239- tags_exclude_is_empty = SQL(
240- "ARRAY[]::TEXT[] = %s" % tags_exclude_array)
241-
242- # Choose the correct expression depending on the find_all_tags flag.
243- def tags_find_all_combinator(find_all_expr, find_any_expr):
244- return SQL(
245- "CASE WHEN BugSubscriptionFilter.find_all_tags "
246- "THEN (%s) ELSE (%s) END" % (find_all_expr, find_any_expr))
247-
248- if len(bugtask.bug.tags) == 0:
249- tag_conditions = [
250- BugSubscriptionFilter.include_any_tags == False,
251- # The subscription's required tags must be an empty set.
252- tags_include_is_empty,
253- # The subscription's excluded tags can be anything so no
254- # condition is needed.
255- ]
256+ quote(tag) for tag in self.tags)
257+ return self._filters_matching_x(
258+ BugSubscriptionFilterTag,
259+ And(
260+ BugSubscriptionFilterTag.filter_id == (
261+ BugSubscriptionFilter.id),
262+ BugSubscriptionFilter.find_all_tags,
263+ self.filter_conditions,
264+ where_condition),
265+ group_by=(
266+ BugSubscriptionFilter.structural_subscription_id,
267+ BugSubscriptionFilter.id),
268+ having=ArrayContains(
269+ SQL(tags_array), ArrayAgg(
270+ BugSubscriptionFilterTag.tag)))
271+
272+ @property
273+ def filters_matching_all_include_tags(self):
274+ """Filters including the bug's tags."""
275+ return self._filters_matching_all_x_tags(
276+ BugSubscriptionFilterTag.include)
277+
278+ @property
279+ def filters_matching_all_exclude_tags(self):
280+ """Filters excluding the bug's tags."""
281+ return self._filters_matching_all_x_tags(
282+ Not(BugSubscriptionFilterTag.include))
283+
284+ @property
285+ def filters_matching_include_tags(self):
286+ """Filters with tag filters including the bug."""
287+ return Union(
288+ self.filters_matching_any_include_tags,
289+ self.filters_matching_all_include_tags)
290+
291+ @property
292+ def filters_matching_exclude_tags(self):
293+ """Filters with tag filters excluding the bug."""
294+ return Union(
295+ self.filters_matching_any_exclude_tags,
296+ self.filters_matching_all_exclude_tags)
297+
298+ @property
299+ def filters_matching_tags(self):
300+ """Filters with tag filters matching the bug."""
301+ if len(self.tags) == 0:
302+ # The filter's required tags must be an empty set. The filter's
303+ # excluded tags can be anything so no condition is needed.
304+ return self.filters_without_include_tags
305 else:
306- tag_conditions = [
307- BugSubscriptionFilter.exclude_any_tags == False,
308- # The bug's tags must contain the subscription's required tags
309- # if find_all_tags is set, else there must be an intersection.
310- Or(tags_include_is_empty,
311- tags_find_all_combinator(
312- "%s @> %s" % (tags_array, tags_include_array),
313- "%s && %s" % (tags_array, tags_include_array))),
314- # The bug's tags must not contain the subscription's excluded
315- # tags if find_all_tags is set, else there must not be an
316- # intersection.
317- Or(tags_exclude_is_empty,
318- tags_find_all_combinator(
319- "NOT (%s @> %s)" % (tags_array, tags_exclude_array),
320- "NOT (%s && %s)" % (tags_array, tags_exclude_array))),
321- ]
322-
323- conditions = [
324- StructuralSubscription.bug_notification_level >= level,
325- Or(
326- # There's no filter or ...
327- BugSubscriptionFilter.id == None,
328- # There is a filter and ...
329- And(
330- # There's no status filter, or there is a status filter
331- # and and it matches.
332- Or(BugSubscriptionFilterStatus.id == None,
333- BugSubscriptionFilterStatus.status == bugtask.status),
334- # There's no importance filter, or there is an importance
335- # filter and it matches.
336- Or(BugSubscriptionFilterImportance.id == None,
337- BugSubscriptionFilterImportance.importance == (
338- bugtask.importance)),
339- # Any number of conditions relating to tags.
340- *tag_conditions)),
341- ]
342-
343- return Store.of(self.__helper.pillar).using(*origin).find(
344- StructuralSubscription, self.__helper.join, *conditions)
345+ return Except(
346+ Union(self.filters_without_include_tags,
347+ self.filters_matching_include_tags),
348+ self.filters_matching_exclude_tags)
349+
350+ @property
351+ def filters_matching(self):
352+ """Filters matching the bug."""
353+ return Intersect(
354+ self.filters_matching_status,
355+ self.filters_matching_importance,
356+ self.filters_matching_tags)
357+
358+ @property
359+ def subscriptions_with_matching_filters(self):
360+ """Subscriptions with one or more filters matching the bug."""
361+ return Select(
362+ # I don't know of a more Storm-like way of doing this.
363+ SQL("filters_matching.structural_subscription_id"),
364+ tables=Alias(self.filters_matching, "filters_matching"))
365+
366+ @property
367+ def subscriptions(self):
368+ return Union(
369+ self.subscriptions_without_filters,
370+ self.subscriptions_with_matching_filters)
371
372=== modified file 'lib/lp/registry/tests/test_structuralsubscriptiontarget.py'
373--- lib/lp/registry/tests/test_structuralsubscriptiontarget.py 2010-11-24 09:42:16 +0000
374+++ lib/lp/registry/tests/test_structuralsubscriptiontarget.py 2010-11-24 09:42:18 +0000
375@@ -67,7 +67,7 @@
376
377
378 class RestrictedStructuralSubscription(StructuralSubscriptionTestBase):
379- # Tests suitable for a target that restricts structural subscriptions.
380+ """Tests suitable for a target that restricts structural subscriptions."""
381
382 def test_target_implements_structural_subscription_target(self):
383 self.assertTrue(verifyObject(IStructuralSubscriptionTarget,
384@@ -128,8 +128,10 @@
385
386
387 class UnrestrictedStructuralSubscription(RestrictedStructuralSubscription):
388- # Tests suitable for a target that does not restrict structural
389- # subscriptions.
390+ """
391+ Tests suitable for a target that does not restrict structural
392+ subscriptions.
393+ """
394
395 def test_structural_subscription_by_ordinary_user(self):
396 # ordinary users can subscribe themselves
397@@ -409,6 +411,35 @@
398 self.bug.tags = ["bar", "baz"]
399 self.assertSubscriptions([self.subscription])
400
401+ def test_getSubscriptionsForBugTask_any_filter_is_a_match(self):
402+ # If a subscription has multiple filters, the subscription is selected
403+ # when any filter is found to match. Put another way, the filters are
404+ # ORed together.
405+ subscription_filter1 = self.subscription.newBugFilter()
406+ subscription_filter1.statuses = [BugTaskStatus.CONFIRMED]
407+ subscription_filter2 = self.subscription.newBugFilter()
408+ subscription_filter2.tags = [u"foo"]
409+
410+ # With the filter the subscription is not found.
411+ self.assertSubscriptions([])
412+
413+ # If the bugtask is adjusted to match the criteria of the first filter
414+ # but not those of the second, the subscription is found.
415+ self.bugtask.transitionToStatus(
416+ BugTaskStatus.CONFIRMED, self.ordinary_subscriber)
417+ self.assertSubscriptions([self.subscription])
418+
419+ # If the filter is adjusted to also match the criteria of the second
420+ # filter, the subscription is still found.
421+ self.bugtask.bug.tags = [u"foo"]
422+ self.assertSubscriptions([self.subscription])
423+
424+ # If the bugtask is adjusted to no longer match the criteria of the
425+ # first filter, the subscription is still found.
426+ self.bugtask.transitionToStatus(
427+ BugTaskStatus.INPROGRESS, self.ordinary_subscriber)
428+ self.assertSubscriptions([self.subscription])
429+
430
431 class TestStructuralSubscriptionForDistro(
432 RestrictedStructuralSubscription, TestCaseWithFactory):