Merge lp:~gary/launchpad/bug723999 into lp:launchpad

Proposed by Gary Poster
Status: Merged
Approved by: Gary Poster
Approved revision: no longer in the source branch.
Merged at revision: 12526
Proposed branch: lp:~gary/launchpad/bug723999
Merge into: lp:launchpad
Diff against target: 1058 lines (+474/-351)
5 files modified
lib/lp/bugs/model/bug.py (+15/-37)
lib/lp/bugs/model/bugtask.py (+14/-69)
lib/lp/bugs/model/structuralsubscription.py (+418/-237)
lib/lp/bugs/model/tests/test_bugtask.py (+5/-3)
lib/lp/bugs/tests/test_structuralsubscriptiontarget.py (+22/-5)
To merge this branch: bzr merge lp:~gary/launchpad/bug723999
Reviewer Review Type Date Requested Status
j.c.sackett (community) Approve
Review via email: mp+52133@code.launchpad.net

Commit message

[r=jcsackett][bug=723999] Reduce the SQL size and expense of calculating the structural subscribers for a given change.

Description of the change

This branch makes significant changes to how we calculate the structural subscribers for a given notification. The previous set-based approach was elegant and may have had similar or even slightly better performance characteristics in some simple cases; however it created gigantic SQL queries and performed poorly when there were a few bugtasks for a bug, and filters for every subscription.

This branch takes a more classic approach to building a SQL query. In tests, this brought a query that was taking 1.2-1.4 seconds to taking 300 milliseconds or less.

It also significantly reduced the SQL needed. SQL is 2/3 size in the simplest case with a single bugtask and no tags; 1/2 size in a more complex case with a single bugtask with tags. Once you have two tasks and on a bug with tags, the new approach's SQL is 1/3 the size, and as you add bugtasks, the ratio gets smaller and smaller.

It's worth noting that Storm support for the WITH statement would really help things. Then I would do one WITH table for the first query, and in the case of the UNION for the tags, I might do another WITH query for the shared bits. As it is, I'm trying to compromise between the cost of duplicating work in the database and the cost of making a separate Storm query. The WITH statement support would also shorten the size of the SQL. I'll be tempted to try and add that to Storm later, now that Postgres supports it.

I ended up having to use some somewhat esoteric SQL. In addition to having a pre-implementation call with Danilo about this, I also ran it past him after I was finished. He seemed pleased with explanations I gave verbally. I have copiously commented the core function, so I hope that you will be able to follow along there as well.

This is a big branch--noticeably bigger than our 800 line goal. "make lint" forced some of the size on me--it's happy, but at a cost. However, even before linting, I was still over the limit. I tried to keep it as small as possible, and have left out some clean-ups that need to happen. In particular, I tried to make minimal changes to the tests, so some methods that no longer serve any purpose in the codebase are left because they are exercised by valuable tests. I will follow up with one or more later branches that clean these things up and move tests around appropriately.

Danilo and I agreed that we might want to remove the features of "include_any_tags" (the filter only accepts any bug that has one or more tag) and "exclude_any_tags" (the filter only accepts bugs that have no tags); and we felt that it might be worth reconsidering how the excluded tags work. However, I kept those out of this branch, as out of scope.

That said, I did make one semantic change to an edge case. Before, NOT find_all_tags meant (in part) that excluded tags (==NOT include) must *all* match. I thought that was opposite what I would expect (and in fact initially implemented the other approach without realizing I was contradicting the tests). After my changes, NOT find_all_tags means that *any* excluded tag must match. For example, if a bug has the tags "foo" and "bar", a filter with find_all_tags = False and "-foo" (NOT include "foo") will now match because "bar" is not "foo". (If find_all_tags = True, the filter will not match. If find_all_tags = False and the filters tags are "-foo" and "-bar", it will not match). Again, I might like to reconsider the broader approach to this feature in the future, but this is not the branch, or the time for that.

I won't say more here, in the hopes that the comments will guide you.

Thank you for looking at this over-sized branch.

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

Gary--

This all largely looks good; I've got a couple of comments/questions in the diff below.

> === modified file 'lib/lp/bugs/model/bugtask.py'
> --- lib/lp/bugs/model/bugtask.py 2011-02-27 19:45:44 +0000
> +++ lib/lp/bugs/model/bugtask.py 2011-03-03 21:04:20 +0000
>
 <snip>
>
 For some reason, having one method just wrap a function from another module bugs me. I see no cleaner way of doing this though, so consider this useless kvetching.
>
> === modified file 'lib/lp/bugs/model/structuralsubscription.py'
> --- lib/lp/bugs/model/structuralsubscription.py 2011-02-21 15:55:48 +0000
> +++ lib/lp/bugs/model/structuralsubscription.py 2011-03-03 21:04:20 +0000
> @@ -471,243 +478,412 @@
>
> def getSubscriptionsForBugTask(self, bugtask, level):
> """See `IStructuralSubscriptionTarget`."""
> - set_builder = BugFilterSetBuilder(
> - bugtask, level, self.__helper.join)
> - return Store.of(self.__helper.pillar).find(
> - StructuralSubscription, In(
> - StructuralSubscription.id,
> - set_builder.subscriptions))
> + # Note that this method does not take into account
> + # structural subscriptions without filters. Since it is only
> + # used for tests at this point, that's not a problem; moreover,
> + # we intend all structural subscriptions to have filters.
> + candidates, filter_id_query = (
> + _get_structural_subscription_filter_id_query(
> + bugtask.bug, [bugtask], level))
> + if not candidates:
> + return EmptyResultSet()
> + return IStore(StructuralSubscription).find(
> + StructuralSubscription,
> + BugSubscriptionFilter.structural_subscription_id ==
> + StructuralSubscription.id,
> + In(BugSubscriptionFilter.id,
> + filter_id_query)).config(distinct=True)
> +
> +
> +def get_structural_subscription_targets(bugtasks):
> + """Return (bugtask, target) pairs for each target of the bugtasks.
> +
> + Each bugtask may be responsible theoretically for 0 or more targets.
> + In practice, each generates one, two or three.
> + """
> + for bugtask in bugtasks:
> + if IStructuralSubscriptionTarget.providedBy(bugtask.target):
> + yield (bugtask, bugtask.target)
> + if bugtask.target.parent_subscription_target is not None:
> + yield (bugtask, bugtask.target.parent_subscription_target)
> + if ISourcePackage.providedBy(bugtask.target):
> + # Distribution series bug tasks with a package have the source
> + # package set as their target, so we add the distroseries
> + # explicitly to the set of subscription targets.
> + yield (bugtask, bugtask.distroseries)
> + if bugtask.milestone is not None:
> + yield (bugtask, bugtask.milestone)
>
 So, if this is only one to three returns per bugtask, is there a combination of yields here that can't happen?
>
> +def _get_all_structural_subscriptions(find, targets, *conditions):
> + """Find the structural subscriptions for the given targets.
> +
> + "find" should be w...

review: Needs Information
Revision history for this message
Gary Poster (gary) wrote :
Download full text (36.2 KiB)

On Mar 3, 2011, at 6:14 PM, j.c.sackett wrote:

> Review: Needs Information
> Gary--
>
> This all largely looks good; I've got a couple of comments/questions in the diff below.
>
>
>> === modified file 'lib/lp/bugs/model/bugtask.py'
>> --- lib/lp/bugs/model/bugtask.py 2011-02-27 19:45:44 +0000
>> +++ lib/lp/bugs/model/bugtask.py 2011-03-03 21:04:20 +0000
>>
> <snip>
>>
> For some reason, having one method just wrap a function from another module bugs me. I see no cleaner way of doing this though, so consider this useless kvetching.

I completely agree, on both counts.

I have an idea on how to make it better for the second branch, but no guarantees.

>>
>> === modified file 'lib/lp/bugs/model/structuralsubscription.py'
>> --- lib/lp/bugs/model/structuralsubscription.py 2011-02-21 15:55:48 +0000
>> +++ lib/lp/bugs/model/structuralsubscription.py 2011-03-03 21:04:20 +0000
>> @@ -471,243 +478,412 @@
>>
>> def getSubscriptionsForBugTask(self, bugtask, level):
>> """See `IStructuralSubscriptionTarget`."""
>> - set_builder = BugFilterSetBuilder(
>> - bugtask, level, self.__helper.join)
>> - return Store.of(self.__helper.pillar).find(
>> - StructuralSubscription, In(
>> - StructuralSubscription.id,
>> - set_builder.subscriptions))
>> + # Note that this method does not take into account
>> + # structural subscriptions without filters. Since it is only
>> + # used for tests at this point, that's not a problem; moreover,
>> + # we intend all structural subscriptions to have filters.
>> + candidates, filter_id_query = (
>> + _get_structural_subscription_filter_id_query(
>> + bugtask.bug, [bugtask], level))
>> + if not candidates:
>> + return EmptyResultSet()
>> + return IStore(StructuralSubscription).find(
>> + StructuralSubscription,
>> + BugSubscriptionFilter.structural_subscription_id ==
>> + StructuralSubscription.id,
>> + In(BugSubscriptionFilter.id,
>> + filter_id_query)).config(distinct=True)
>> +
>> +
>> +def get_structural_subscription_targets(bugtasks):
>> + """Return (bugtask, target) pairs for each target of the bugtasks.
>> +
>> + Each bugtask may be responsible theoretically for 0 or more targets.
>> + In practice, each generates one, two or three.
>> + """
>> + for bugtask in bugtasks:
>> + if IStructuralSubscriptionTarget.providedBy(bugtask.target):
>> + yield (bugtask, bugtask.target)
>> + if bugtask.target.parent_subscription_target is not None:
>> + yield (bugtask, bugtask.target.parent_subscription_target)
>> + if ISourcePackage.providedBy(bugtask.target):
>> + # Distribution series bug tasks with a package have the source
>> + # package set as their target, so we add the distroseries
>> + # explicitly to the set of subscription targets.
>> + yield (bugtask, bugtask.distroseries)
>> + if bugtask.milestone is not None:
>> + yield (bugtask, bugtask.milestone)
>>
> So, if this is on...

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

Re: the three vs four, I'm fine leaving it as is, I was just confused. A quick comment wouldn't suck, but I'm not a stickler.

Re: breaking the huge function into little bits. I think you're probably right about it not being doable. I might agree with you on the ORM vs raw SQL design patterns, but let's take that discussion out of this medium and to irc or email.

For everything else, thanks for agreeing to the changes and thanks for this impressive branch.

review: Approve
Revision history for this message
Gary Poster (gary) wrote :

Thank you for the review!

bug 728818

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/model/bug.py'
2--- lib/lp/bugs/model/bug.py 2011-03-01 05:05:26 +0000
3+++ lib/lp/bugs/model/bug.py 2011-03-04 02:31:59 +0000
4@@ -143,7 +143,6 @@
5 )
6 from lp.bugs.interfaces.bugnotification import IBugNotificationSet
7 from lp.bugs.interfaces.bugtask import (
8- BugTaskSearchParams,
9 BugTaskStatus,
10 IBugTaskSet,
11 UNRESOLVED_BUGTASK_STATUSES,
12@@ -151,9 +150,6 @@
13 from lp.bugs.interfaces.bugtracker import BugTrackerType
14 from lp.bugs.interfaces.bugwatch import IBugWatchSet
15 from lp.bugs.interfaces.cve import ICveSet
16-from lp.bugs.interfaces.structuralsubscription import (
17- IStructuralSubscriptionTarget,
18- )
19 from lp.bugs.mail.bugnotificationrecipients import BugNotificationRecipients
20 from lp.bugs.model.bugattachment import BugAttachment
21 from lp.bugs.model.bugbranch import BugBranch
22@@ -170,6 +166,9 @@
23 NullBugTask,
24 )
25 from lp.bugs.model.bugwatch import BugWatch
26+from lp.bugs.model.structuralsubscription import (
27+ get_all_structural_subscriptions,
28+ )
29 from lp.hardwaredb.interfaces.hwdb import IHWSubmissionBugSet
30 from lp.registry.interfaces.distribution import IDistribution
31 from lp.registry.interfaces.distributionsourcepackage import (
32@@ -441,8 +440,8 @@
33 @property
34 def indexed_messages(self):
35 """See `IMessageTarget`."""
36- # Note that this is a decorated result set, so will cache its value (in
37- # the absence of slices)
38+ # Note that this is a decorated result set, so will cache its
39+ # value (in the absence of slices)
40 return self._indexed_messages(include_content=True)
41
42 def _indexed_messages(self, include_content=False, include_parents=True):
43@@ -554,7 +553,6 @@
44 def bugtasks(self):
45 """See `IBug`."""
46 # \o/ circular imports.
47- from lp.bugs.model.bugwatch import BugWatch
48 from lp.registry.model.distribution import Distribution
49 from lp.registry.model.distroseries import DistroSeries
50 from lp.registry.model.product import Product
51@@ -562,17 +560,18 @@
52 from lp.registry.model.sourcepackagename import SourcePackageName
53 store = Store.of(self)
54 tasks = list(store.find(BugTask, BugTask.bugID == self.id))
55- # The bugtasks attribute is iterated in the API and web services, so it
56- # needs to preload all related data otherwise late evaluation is
57- # triggered in both places. Separately, bugtask_sort_key requires
58- # the related products, series, distros, distroseries and source
59- # package names to be loaded.
60+ # The bugtasks attribute is iterated in the API and web
61+ # services, so it needs to preload all related data otherwise
62+ # late evaluation is triggered in both places. Separately,
63+ # bugtask_sort_key requires the related products, series,
64+ # distros, distroseries and source package names to be loaded.
65 ids = set(map(operator.attrgetter('assigneeID'), tasks))
66 ids.update(map(operator.attrgetter('ownerID'), tasks))
67 ids.discard(None)
68 if ids:
69 list(getUtility(IPersonSet).getPrecachedPersonsFromIDs(
70 ids, need_validity=True))
71+
72 def load_something(attrname, klass):
73 ids = set(map(operator.attrgetter(attrname), tasks))
74 ids.discard(None)
75@@ -1401,8 +1400,8 @@
76
77 def getMessagesForView(self, slice_info):
78 """See `IBug`."""
79- # Note that this function and indexed_messages have significant overlap
80- # and could stand to be refactored.
81+ # Note that this function and indexed_messages have significant
82+ # overlap and could stand to be refactored.
83 slices = []
84 if slice_info is not None:
85 # NB: This isn't a full implementation of the slice protocol,
86@@ -1438,6 +1437,7 @@
87 BugMessage.bug==self.id,
88 *ranges)
89 result.order_by(BugMessage.index, MessageChunk.sequence)
90+
91 def eager_load_owners(rows):
92 owners = set()
93 for row in rows:
94@@ -2183,29 +2183,7 @@
95 @freeze(StructuralSubscriptionSet)
96 def structural_subscriptions(self):
97 """Structural subscriptions to the bug's targets."""
98- query_arguments = []
99- for bugtask in self.bug.bugtasks:
100- if IStructuralSubscriptionTarget.providedBy(bugtask.target):
101- query_arguments.append((bugtask.target, bugtask))
102- if bugtask.target.parent_subscription_target is not None:
103- query_arguments.append(
104- (bugtask.target.parent_subscription_target, bugtask))
105- if ISourcePackage.providedBy(bugtask.target):
106- # Distribution series bug tasks with a package have the source
107- # package set as their target, so we add the distroseries
108- # explicitly to the set of subscription targets.
109- query_arguments.append((bugtask.distroseries, bugtask))
110- if bugtask.milestone is not None:
111- query_arguments.append((bugtask.milestone, bugtask))
112- # Build the query.
113- empty = EmptyResultSet()
114- union = lambda left, right: (
115- removeSecurityProxy(left).union(
116- removeSecurityProxy(right)))
117- queries = (
118- target.getSubscriptionsForBugTask(bugtask, self.level)
119- for target, bugtask in query_arguments)
120- return reduce(union, queries, empty)
121+ return get_all_structural_subscriptions(self.bug.bugtasks)
122
123 @cachedproperty
124 @freeze(BugSubscriberSet)
125
126=== modified file 'lib/lp/bugs/model/bugtask.py'
127--- lib/lp/bugs/model/bugtask.py 2011-02-27 19:45:44 +0000
128+++ lib/lp/bugs/model/bugtask.py 2011-03-04 02:31:59 +0000
129@@ -100,7 +100,6 @@
130 )
131 from lp.app.enums import ServiceUsage
132 from lp.app.errors import NotFoundError
133-from lp.bugs.enum import BugNotificationLevel
134 from lp.bugs.interfaces.bug import IBugSet
135 from lp.bugs.interfaces.bugattachment import BugAttachmentType
136 from lp.bugs.interfaces.bugnomination import BugNominationStatus
137@@ -130,12 +129,14 @@
138 UserCannotEditBugTaskMilestone,
139 UserCannotEditBugTaskStatus,
140 )
141-from lp.bugs.interfaces.structuralsubscription import (
142- IStructuralSubscriptionTarget,
143- )
144 from lp.bugs.model.bugnomination import BugNomination
145 from lp.bugs.model.bugsubscription import BugSubscription
146-from lp.bugs.model.structuralsubscription import StructuralSubscription
147+from lp.bugs.model.structuralsubscription import (
148+ get_all_structural_subscriptions,
149+ get_structural_subscribers_for_bugtasks,
150+ get_structural_subscription_targets,
151+ StructuralSubscription,
152+ )
153 from lp.registry.interfaces.distribution import (
154 IDistribution,
155 IDistributionSet,
156@@ -2096,7 +2097,8 @@
157 """EXISTS (
158 SELECT id FROM BugBranch WHERE BugBranch.bug=Bug.id)
159 """)
160- elif params.linked_branches == BugBranchSearch.BUGS_WITHOUT_BRANCHES:
161+ elif (params.linked_branches ==
162+ BugBranchSearch.BUGS_WITHOUT_BRANCHES):
163 extra_clauses.append(
164 """NOT EXISTS (
165 SELECT id FROM BugBranch WHERE BugBranch.bug=Bug.id)
166@@ -2130,6 +2132,7 @@
167 if not decorators:
168 decorator = lambda x: x
169 else:
170+
171 def decorator(obj):
172 for decor in decorators:
173 obj = decor(obj)
174@@ -3108,71 +3111,13 @@
175 Each bugtask may be responsible theoretically for 0 or more targets.
176 In practice, each generates one, two or three.
177 """
178- for bugtask in bugtasks:
179- if IStructuralSubscriptionTarget.providedBy(bugtask.target):
180- yield (bugtask, bugtask.target)
181- if bugtask.target.parent_subscription_target is not None:
182- yield (bugtask, bugtask.target.parent_subscription_target)
183- if ISourcePackage.providedBy(bugtask.target):
184- # Distribution series bug tasks with a package have the source
185- # package set as their target, so we add the distroseries
186- # explicitly to the set of subscription targets.
187- yield (bugtask, bugtask.distroseries)
188- if bugtask.milestone is not None:
189- yield (bugtask, bugtask.milestone)
190+ return get_structural_subscription_targets(bugtasks)
191
192- def getAllStructuralSubscriptions(self, bugtasks, recipient):
193+ def getAllStructuralSubscriptions(self, bugtasks, recipient=None):
194 """See `IBugTaskSet`."""
195- targets = [target for bugtask, target
196- in self._getStructuralSubscriptionTargets(bugtasks)]
197- if len(targets) == 0:
198- return EmptyResultSet()
199- union = lambda left, right: (
200- removeSecurityProxy(left).union(
201- removeSecurityProxy(right)))
202- queries = (
203- target.getSubscriptions(recipient) for target in targets)
204- return reduce(union, queries)
205+ return get_all_structural_subscriptions(bugtasks, recipient)
206
207 def getStructuralSubscribers(self, bugtasks, recipients=None, level=None):
208 """See `IBugTaskSet`."""
209- query_arguments = list(
210- self._getStructuralSubscriptionTargets(bugtasks))
211-
212- if len(query_arguments) == 0:
213- return EmptyResultSet()
214-
215- if level is None:
216- # If level is not specified, default to NOTHING so that all
217- # subscriptions are found.
218- level = BugNotificationLevel.NOTHING
219-
220- # Build the query.
221- union = lambda left, right: (
222- removeSecurityProxy(left).union(
223- removeSecurityProxy(right)))
224- queries = (
225- target.getSubscriptionsForBugTask(bugtask, level)
226- for bugtask, target in query_arguments)
227- subscriptions = reduce(union, queries)
228-
229- # Pull all the subscriptions in.
230- subscriptions = list(subscriptions)
231-
232- # Prepare a query for the subscribers.
233- from lp.registry.model.person import Person
234- subscribers = IStore(Person).find(
235- Person, Person.id.is_in(
236- removeSecurityProxy(subscription).subscriberID
237- for subscription in subscriptions))
238-
239- if recipients is not None:
240- # We need to process subscriptions, so pull all the
241- # subscribers into the cache, then update recipients
242- # with the subscriptions.
243- subscribers = list(subscribers)
244- for subscription in subscriptions:
245- recipients.addStructuralSubscriber(
246- subscription.subscriber, subscription.target)
247-
248- return subscribers
249+ return get_structural_subscribers_for_bugtasks(
250+ bugtasks, recipients, level)
251
252=== modified file 'lib/lp/bugs/model/structuralsubscription.py'
253--- lib/lp/bugs/model/structuralsubscription.py 2011-02-21 15:55:48 +0000
254+++ lib/lp/bugs/model/structuralsubscription.py 2011-03-04 02:31:59 +0000
255@@ -3,6 +3,9 @@
256
257 __metaclass__ = type
258 __all__ = [
259+ 'get_all_structural_subscriptions',
260+ 'get_structural_subscribers_for_bugtasks',
261+ 'get_structural_subscription_targets',
262 'StructuralSubscription',
263 'StructuralSubscriptionTargetMixin',
264 ]
265@@ -17,12 +20,11 @@
266
267 from storm.base import Storm
268 from storm.expr import (
269- Alias,
270 And,
271 CompoundOper,
272- Except,
273+ Count,
274 In,
275- Intersect,
276+ Join,
277 LeftJoin,
278 NamedFunc,
279 Not,
280@@ -31,7 +33,10 @@
281 SQL,
282 Union,
283 )
284-from storm.store import Store
285+from storm.store import (
286+ Store,
287+ EmptyResultSet,
288+ )
289 from zope.component import (
290 adapts,
291 getUtility,
292@@ -42,6 +47,12 @@
293 from canonical.database.sqlbase import quote
294 from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
295 from canonical.launchpad.interfaces.lpstorm import IStore
296+from lp.bugs.interfaces.structuralsubscription import (
297+ IStructuralSubscription,
298+ IStructuralSubscriptionTarget,
299+ IStructuralSubscriptionTargetHelper,
300+ )
301+from lp.bugs.model.bugsubscription import BugSubscription
302 from lp.bugs.model.bugsubscriptionfilter import BugSubscriptionFilter
303 from lp.bugs.model.bugsubscriptionfilterimportance import (
304 BugSubscriptionFilterImportance,
305@@ -67,11 +78,7 @@
306 from lp.registry.interfaces.product import IProduct
307 from lp.registry.interfaces.productseries import IProductSeries
308 from lp.registry.interfaces.projectgroup import IProjectGroup
309-from lp.bugs.interfaces.structuralsubscription import (
310- IStructuralSubscription,
311- IStructuralSubscriptionTarget,
312- IStructuralSubscriptionTargetHelper,
313- )
314+from lp.registry.interfaces.sourcepackage import ISourcePackage
315 from lp.services.propertycache import cachedproperty
316
317
318@@ -471,243 +478,417 @@
319
320 def getSubscriptionsForBugTask(self, bugtask, level):
321 """See `IStructuralSubscriptionTarget`."""
322- set_builder = BugFilterSetBuilder(
323- bugtask, level, self.__helper.join)
324- return Store.of(self.__helper.pillar).find(
325- StructuralSubscription, In(
326- StructuralSubscription.id,
327- set_builder.subscriptions))
328+ # Note that this method does not take into account
329+ # structural subscriptions without filters. Since it is only
330+ # used for tests at this point, that's not a problem; moreover,
331+ # we intend all structural subscriptions to have filters.
332+ candidates, filter_id_query = (
333+ _get_structural_subscription_filter_id_query(
334+ bugtask.bug, [bugtask], level))
335+ if not candidates:
336+ return EmptyResultSet()
337+ return IStore(StructuralSubscription).find(
338+ StructuralSubscription,
339+ BugSubscriptionFilter.structural_subscription_id ==
340+ StructuralSubscription.id,
341+ In(BugSubscriptionFilter.id,
342+ filter_id_query)).config(distinct=True)
343+
344+
345+def get_structural_subscription_targets(bugtasks):
346+ """Return (bugtask, target) pairs for each target of the bugtasks.
347+
348+ Each bugtask may be responsible theoretically for 0 or more targets.
349+ In practice, each generates one, two or three.
350+ """
351+ for bugtask in bugtasks:
352+ if IStructuralSubscriptionTarget.providedBy(bugtask.target):
353+ yield (bugtask, bugtask.target)
354+ if bugtask.target.parent_subscription_target is not None:
355+ yield (bugtask, bugtask.target.parent_subscription_target)
356+ # This can probably be an elif. Determining conclusively
357+ # whether it can be is not a priority at this time. The
358+ # docstring says one, two, or three targets per bugtask because
359+ # of the belief that this could be an elif; otherwise, it would
360+ # be one, two, three or four.
361+ if ISourcePackage.providedBy(bugtask.target):
362+ # Distribution series bug tasks with a package have the source
363+ # package set as their target, so we add the distroseries
364+ # explicitly to the set of subscription targets.
365+ yield (bugtask, bugtask.distroseries)
366+ if bugtask.milestone is not None:
367+ yield (bugtask, bugtask.milestone)
368+
369+
370+def _get_all_structural_subscriptions(find, targets, *conditions):
371+ """Find the structural subscriptions for the given targets.
372+
373+ :param find: what to find (typically StructuralSubscription or
374+ StructuralSubscription.id).
375+ :param targets: an iterable of (bugtask, target) pairs, as returned by
376+ get_structural_subscription_targets.
377+ :param conditions: additional conditions to filter the results.
378+ """
379+ targets = set(target for bugtask, target in targets)
380+ target_descriptions = [
381+ IStructuralSubscriptionTargetHelper(target).join
382+ for target in targets]
383+ return list(
384+ IStore(StructuralSubscription).find(
385+ find, Or(*target_descriptions), *conditions))
386+
387+
388+def get_all_structural_subscriptions(bugtasks, person=None):
389+ if not bugtasks:
390+ return EmptyResultSet()
391+ conditions = []
392+ if person is not None:
393+ conditions.append(
394+ StructuralSubscription.subscriber == person)
395+ return _get_all_structural_subscriptions(
396+ StructuralSubscription,
397+ get_structural_subscription_targets(bugtasks),
398+ *conditions)
399+
400+
401+def _get_structural_subscribers(candidates, filter_id_query, recipients):
402+ if not candidates:
403+ return EmptyResultSet()
404+ # This is here because of a circular import.
405+ from lp.registry.model.person import Person
406+ source = IStore(StructuralSubscription).using(
407+ StructuralSubscription,
408+ # XXX gary 2011-03-03 bug 728818
409+ # We need to do this LeftJoin because we still have structural
410+ # subscriptions without filters in qastaging and production.
411+ # Once we do not, we can just use a Join. Also see constraints
412+ # below.
413+ LeftJoin(BugSubscriptionFilter,
414+ BugSubscriptionFilter.structural_subscription_id ==
415+ StructuralSubscription.id),
416+ Join(Person,
417+ Person.id == StructuralSubscription.subscriberID),
418+ )
419+ constraints = [
420+ # XXX gary 2011-03-03 bug 728818
421+ # We need to do this Or because we still have structural
422+ # subscriptions without filters in qastaging and production.
423+ # Once we do not, we can simplify this to just
424+ # "In(BugSubscriptionFilter.id, filter_id_query)". Also see
425+ # LeftJoin above.
426+ Or(In(BugSubscriptionFilter.id, filter_id_query),
427+ And(In(StructuralSubscription.id, candidates),
428+ BugSubscriptionFilter.id == None))]
429+ if recipients is None:
430+ return source.find(
431+ Person, *constraints).config(distinct=True).order_by()
432+ else:
433+ subscribers = []
434+ query_results = source.find(
435+ (Person, StructuralSubscription),
436+ *constraints).config(distinct=True)
437+ for person, subscription in query_results:
438+ # Set up results.
439+ if person not in recipients:
440+ subscribers.append(person)
441+ recipients.addStructuralSubscriber(
442+ person, subscription.target)
443+ return subscribers
444+
445+
446+def get_structural_subscribers_for_bugtasks(bugtasks,
447+ recipients=None,
448+ level=None):
449+ """Return subscribers for structural filters for the bugtasks at "level".
450+
451+ :param bugtasks: an iterable of bugtasks. All must be for the same bug.
452+ :param recipients: a BugNotificationRecipients object or None.
453+ Populates if given.
454+ :param level: a level from lp.bugs.enum.BugNotificationLevel.
455+
456+ Excludes structural subscriptions for people who are directly subscribed
457+ to the bug."""
458+ if not bugtasks:
459+ return EmptyResultSet()
460+ bugs = set(bugtask.bug for bugtask in bugtasks)
461+ if len(bugs) > 1:
462+ raise NotImplementedError('Each bugtask must be from the same bug.')
463+ bug = bugs.pop()
464+ candidates, query = _get_structural_subscription_filter_id_query(
465+ bug, bugtasks, level)
466+ return _get_structural_subscribers(candidates, query, recipients)
467
468
469 class ArrayAgg(NamedFunc):
470+ "Aggregate values (within a GROUP BY) into an array."
471 __slots__ = ()
472 name = "ARRAY_AGG"
473
474
475 class ArrayContains(CompoundOper):
476+ "True iff the left side is a superset of the right side."
477 __slots__ = ()
478 oper = "@>"
479
480
481-class BugFilterSetBuilder:
482- """A convenience class to build queries for getSubscriptionsForBugTask."""
483-
484- def __init__(self, bugtask, level, join_condition):
485- """Initialize a new set builder for bug filters.
486-
487- :param bugtask: The `IBugTask` to match against.
488- :param level: A member of `BugNotificationLevel`.
489- :param join_condition: A condition for selecting structural
490- subscriptions. Generally this should limit the subscriptions to a
491- particular target (i.e. project or distribution).
492- """
493- self.status = bugtask.status
494- self.importance = bugtask.importance
495- # The list() gets around some weirdness with security proxies; Storm
496- # does not know how to compile an expression with a proxied list.
497- self.tags = list(bugtask.bug.tags)
498- # Set up common conditions.
499- self.base_conditions = join_condition
500- # Set up common filter conditions.
501- self.filter_conditions = And(
502- BugSubscriptionFilter.bug_notification_level >= level,
503- self.base_conditions)
504- if len(self.tags) == 0:
505- self.filter_conditions = And(
506- # When the bug has no tags, filters with include_any_tags set
507- # can never match.
508- Not(BugSubscriptionFilter.include_any_tags),
509- self.filter_conditions)
510- else:
511- self.filter_conditions = And(
512- # When the bug has tags, filters with exclude_any_tags set can
513- # never match.
514- Not(BugSubscriptionFilter.exclude_any_tags),
515- self.filter_conditions)
516-
517- @property
518- def subscriptions_without_filters(self):
519- """Subscriptions without filters."""
520- return Select(
521- StructuralSubscription.id,
522- tables=(
523- StructuralSubscription,
524- LeftJoin(
525- BugSubscriptionFilter,
526- BugSubscriptionFilter.structural_subscription_id == (
527- StructuralSubscription.id))),
528- where=And(
529- BugSubscriptionFilter.id == None,
530- self.base_conditions))
531-
532- def _filters_matching_x(self, join, where_condition, **extra):
533- """Return an expression yielding `(subscription_id, filter_id)` rows.
534-
535- The expressions returned by this function are used in set (union,
536- intersect, except) operations at the *filter* level. However, the
537- interesting result of these set operations is the structural
538- subscription, hence both columns are included in the expressions
539- generated. Since a structural subscription can have zero or more
540- filters, and a filter can never be associated with more than one
541- subscription, the set operations are unaffected.
542- """
543- return Select(
544- columns=(
545- # Alias this column so it can be selected in
546- # subscriptions_matching.
547- Alias(
548- BugSubscriptionFilter.structural_subscription_id,
549- "structural_subscription_id"),
550- BugSubscriptionFilter.id),
551- tables=(
552- StructuralSubscription, BugSubscriptionFilter, join),
553- where=And(
554- BugSubscriptionFilter.structural_subscription_id == (
555- StructuralSubscription.id),
556- self.filter_conditions,
557- where_condition),
558- **extra)
559-
560- @property
561- def filters_matching_status(self):
562- """Filters with the given bugtask's status."""
563- join = LeftJoin(
564- BugSubscriptionFilterStatus,
565- BugSubscriptionFilterStatus.filter_id == (
566- BugSubscriptionFilter.id))
567- condition = Or(
568- BugSubscriptionFilterStatus.id == None,
569- BugSubscriptionFilterStatus.status == self.status)
570- return self._filters_matching_x(join, condition)
571-
572- @property
573- def filters_matching_importance(self):
574- """Filters with the given bugtask's importance."""
575- join = LeftJoin(
576- BugSubscriptionFilterImportance,
577- BugSubscriptionFilterImportance.filter_id == (
578- BugSubscriptionFilter.id))
579- condition = Or(
580- BugSubscriptionFilterImportance.id == None,
581- BugSubscriptionFilterImportance.importance == self.importance)
582- return self._filters_matching_x(join, condition)
583-
584- @property
585- def filters_without_include_tags(self):
586- """Filters with no tags required."""
587- join = LeftJoin(
588- BugSubscriptionFilterTag,
589- And(BugSubscriptionFilterTag.filter_id == (
590- BugSubscriptionFilter.id),
591- BugSubscriptionFilterTag.include))
592- return self._filters_matching_x(
593- join, BugSubscriptionFilterTag.id == None)
594-
595- @property
596- def filters_matching_any_include_tags(self):
597- """Filters including any of the bug's tags."""
598- condition = And(
599- BugSubscriptionFilterTag.filter_id == (
600- BugSubscriptionFilter.id),
601- BugSubscriptionFilterTag.include,
602- Not(BugSubscriptionFilter.find_all_tags),
603- In(BugSubscriptionFilterTag.tag, self.tags))
604- return self._filters_matching_x(
605- BugSubscriptionFilterTag, condition)
606-
607- @property
608- def filters_matching_any_exclude_tags(self):
609- """Filters excluding any of the bug's tags."""
610- condition = And(
611- BugSubscriptionFilterTag.filter_id == (
612- BugSubscriptionFilter.id),
613- Not(BugSubscriptionFilterTag.include),
614- Not(BugSubscriptionFilter.find_all_tags),
615- In(BugSubscriptionFilterTag.tag, self.tags))
616- return self._filters_matching_x(
617- BugSubscriptionFilterTag, condition)
618-
619- def _filters_matching_all_x_tags(self, where_condition):
620- """Return an expression yielding `(subscription_id, filter_id)` rows.
621-
622- This joins to `BugSubscriptionFilterTag` and calls up to
623- `_filters_matching_x`, and groups by filter. Conditions are added to
624- ensure that all rows in each group are a subset of the bug's tags.
625- """
626- tags_array = "ARRAY[%s]::TEXT[]" % ",".join(
627- quote(tag) for tag in self.tags)
628- return self._filters_matching_x(
629- BugSubscriptionFilterTag,
630- And(
631- BugSubscriptionFilterTag.filter_id == (
632- BugSubscriptionFilter.id),
633- BugSubscriptionFilter.find_all_tags,
634- self.filter_conditions,
635- where_condition),
636- group_by=(
637- BugSubscriptionFilter.structural_subscription_id,
638- BugSubscriptionFilter.id),
639- having=ArrayContains(
640- SQL(tags_array), ArrayAgg(
641- BugSubscriptionFilterTag.tag)))
642-
643- @property
644- def filters_matching_all_include_tags(self):
645- """Filters including the bug's tags."""
646- return self._filters_matching_all_x_tags(
647- BugSubscriptionFilterTag.include)
648-
649- @property
650- def filters_matching_all_exclude_tags(self):
651- """Filters excluding the bug's tags."""
652- return self._filters_matching_all_x_tags(
653- Not(BugSubscriptionFilterTag.include))
654-
655- @property
656- def filters_matching_include_tags(self):
657- """Filters with tag filters including the bug."""
658- return Union(
659- self.filters_matching_any_include_tags,
660- self.filters_matching_all_include_tags)
661-
662- @property
663- def filters_matching_exclude_tags(self):
664- """Filters with tag filters excluding the bug."""
665- return Union(
666- self.filters_matching_any_exclude_tags,
667- self.filters_matching_all_exclude_tags)
668-
669- @property
670- def filters_matching_tags(self):
671- """Filters with tag filters matching the bug."""
672- if len(self.tags) == 0:
673- # The filter's required tags must be an empty set. The filter's
674- # excluded tags can be anything so no condition is needed.
675- return self.filters_without_include_tags
676- else:
677- return Except(
678- Union(self.filters_without_include_tags,
679- self.filters_matching_include_tags),
680- self.filters_matching_exclude_tags)
681-
682- @property
683- def filters_matching(self):
684- """Filters matching the bug."""
685- return Intersect(
686- self.filters_matching_status,
687- self.filters_matching_importance,
688- self.filters_matching_tags)
689-
690- @property
691- def subscriptions_with_matching_filters(self):
692- """Subscriptions with one or more filters matching the bug."""
693- return Select(
694- # I don't know of a more Storm-like way of doing this.
695- SQL("filters_matching.structural_subscription_id"),
696- tables=Alias(self.filters_matching, "filters_matching"))
697-
698- @property
699- def subscriptions(self):
700- return Union(
701- self.subscriptions_without_filters,
702- self.subscriptions_with_matching_filters)
703+class ArrayIntersects(CompoundOper):
704+ "True iff the left side shares at least one element with the right side."
705+ __slots__ = ()
706+ oper = "&&"
707+
708+
709+def _get_structural_subscription_filter_id_query(bug, bugtasks, level):
710+ """Helper function.
711+
712+ This provides the core implementation for
713+ get_structural_subscribers_for_bug and
714+ get_structural_subscribers_for_bugtask.
715+
716+ :param bug: a bug.
717+ :param bugtasks: an iterable of one or more bugtasks of the bug.
718+ :param level: a notification level.
719+ """
720+ # We get the ids because we need to use group by in order to
721+ # look at the filters' tags in aggregate. Once we have the ids,
722+ # we can get the full set of what we need in subsuming or
723+ # subsequent SQL calls.
724+ # (Aside 1: We could in theory get all the fields we wanted with
725+ # a hack--we could use an aggregrate function like max to get
726+ # fields that we know will be unique--but Storm would not like
727+ # it.)
728+ # (Aside 2: IMO Postgres should allow getting other fields if
729+ # the group-by key is a primary key and the other fields desired
730+ # are other values from the same table as the group-by key, or
731+ # values of a table linked by a foreign key from the same table
732+ # as the group-by key...but that's dreaming.)
733+ # See the docstring of get_structural_subscription_targets.
734+ query_arguments = list(
735+ get_structural_subscription_targets(bugtasks))
736+ assert len(query_arguments) > 0, (
737+ 'Programmer error: expected query arguments')
738+ # With large numbers of filters in the system, it's fastest in our
739+ # tests if we get a set of structural subscriptions pertinent to the
740+ # given targets, and then work with that. It also comes in handy
741+ # when we have to do a union, because we can share the work across
742+ # the two queries.
743+ # We will exclude people who have a direct subscription to the bug.
744+ filters = [
745+ Not(In(StructuralSubscription.subscriberID,
746+ Select(BugSubscription.person_id,
747+ BugSubscription.bug == bug)))]
748+ candidates = _get_all_structural_subscriptions(
749+ StructuralSubscription.id, query_arguments, *filters)
750+ if not candidates:
751+ # If there are no structural subscriptions for these targets,
752+ # then we don't need to look at the importance, status, and
753+ # tags. We're done.
754+ return None, None
755+ # The "select_args" dictionary holds the arguments that we will
756+ # pass to one or more SELECT clauses. We start with what will
757+ # become the FROM clause. We always want the following Joins,
758+ # so we can add them here at the beginning.
759+ select_args = {
760+ 'tables': [
761+ StructuralSubscription,
762+ Join(BugSubscriptionFilter,
763+ BugSubscriptionFilter.structural_subscription_id ==
764+ StructuralSubscription.id),
765+ LeftJoin(BugSubscriptionFilterStatus,
766+ BugSubscriptionFilterStatus.filter_id ==
767+ BugSubscriptionFilter.id),
768+ LeftJoin(BugSubscriptionFilterImportance,
769+ BugSubscriptionFilterImportance.filter_id ==
770+ BugSubscriptionFilter.id),
771+ LeftJoin(BugSubscriptionFilterTag,
772+ BugSubscriptionFilterTag.filter_id ==
773+ BugSubscriptionFilter.id)]}
774+ # The "conditions" list will eventually be passed to a Storm
775+ # "And" function, and then become the WHERE clause of our SELECT.
776+ conditions = [In(StructuralSubscription.id, candidates)]
777+ # Handling notification level is trivial, so we include that first.
778+ if level is not None:
779+ conditions.append(
780+ BugSubscriptionFilter.bug_notification_level >= level)
781+ # Now we handle importance and status, which are per bugtask.
782+ # What we do is loop through the collection of bugtask, target
783+ # in query_arguments. Each bugtask will have one or more
784+ # targets that we have to check. We figure out how to describe each
785+ # target using the useful IStructuralSubscriptionTargetHelper
786+ # adapter, which has a "join" attribute on it that tells us how
787+ # to distinguish that target. Once we have all of the target
788+ # descriptins, we OR those together, and say that the filters
789+ # for those targets must either have no importance or match the
790+ # associated bugtask's importance; and have no status or match
791+ # the bugtask's status. Once we have looked at all of the
792+ # bugtasks, we OR all of those per-bugtask target comparisons
793+ # together, and we are done with the status and importance.
794+ # The "outer_or_conditions" list holds the full clauses for each
795+ # bugtask.
796+ outer_or_conditions = []
797+ # The "or_target_conditions" list holds the clauses for each target,
798+ # and is reset for each new bugtask.
799+ or_target_conditions = []
800+
801+ def handle_bugtask_conditions(bugtask):
802+ """Helper function for building status and importance clauses.
803+
804+ Call with the previous bugtask when the bugtask changes in
805+ the iteration of query_arguments, and call with the last
806+ bugtask when the iteration is complete."""
807+ if or_target_conditions:
808+ outer_or_conditions.append(
809+ And(Or(*or_target_conditions),
810+ Or(BugSubscriptionFilterImportance.importance ==
811+ bugtask.importance,
812+ BugSubscriptionFilterImportance.importance == None),
813+ Or(BugSubscriptionFilterStatus.status == bugtask.status,
814+ BugSubscriptionFilterStatus.status == None)))
815+ del or_target_conditions[:]
816+ last_bugtask = None
817+ for bugtask, target in query_arguments:
818+ if last_bugtask is not bugtask:
819+ handle_bugtask_conditions(last_bugtask)
820+ last_bugtask = bugtask
821+ or_target_conditions.append(
822+ IStructuralSubscriptionTargetHelper(target).join)
823+ # We know there will be at least one bugtask, because we already
824+ # escaped early "if len(query_arguments) == 0".
825+ handle_bugtask_conditions(bugtask)
826+ conditions.append(Or(*outer_or_conditions))
827+ # Now we handle tags. If the bug has no tags, this is
828+ # relatively easy. Otherwise, not so much.
829+ tags = list(bug.tags) # This subtly removes the security proxy on
830+ # the list. Strings are never security-proxied, so we don't have
831+ # to worry about them.
832+ if len(tags) == 0:
833+ # The bug has no tags. We should leave out filters that
834+ # require any generic non-empty set of tags
835+ # (BugSubscriptionFilter.include_any_tags), which we do with
836+ # the conditions. Then we can finish up the WHERE clause.
837+ # Then we have to make sure that the filter does not require
838+ # any *specific* tags. We do that with a GROUP BY on the
839+ # filters, and then a HAVING clause that aggregates the
840+ # BugSubscriptionFilterTags that are set to "include" the
841+ # tag. (If it is not an include, that is an exclude, and a
842+ # bug without tags will not have a particular tag, so we can
843+ # ignore those in this case.) This requires a CASE
844+ # statement within the COUNT. After this, we are done, and
845+ # we return the fully formed SELECT query object.
846+ conditions.append(Not(BugSubscriptionFilter.include_any_tags))
847+ select_args['where'] = And(*conditions)
848+ select_args['group_by'] = (BugSubscriptionFilter.id,)
849+ select_args['having'] = Count(
850+ SQL('CASE WHEN BugSubscriptionFilterTag.include '
851+ 'THEN BugSubscriptionFilterTag.tag END'))==0
852+ return candidates, Select(BugSubscriptionFilter.id, **select_args)
853+ else:
854+ # The bug has some tags. This will require a bit of fancy
855+ # footwork. First, though, we will simply want to leave out
856+ # filters that should only match bugs without tags.
857+ conditions.append(Not(BugSubscriptionFilter.exclude_any_tags))
858+ # We're going to have to do a union with another query. One
859+ # query will handle filters that are marked to include *any*
860+ # of the filter's selected tags, and the other query will
861+ # handle filters that include *all* of the filter's selected
862+ # tags (as determined by BugSubscriptionFilter.find_all_tags).
863+ # Every aspect of the unioned queries' WHERE clauses *other
864+ # than tags* will need to be the same. We could try making a
865+ # temporary table for the shared results, but that would
866+ # involve another separate Postgres call, and I think that
867+ # we've already gotten the big win by separating out the
868+ # structural subscriptions into "candidates," above.
869+ #
870+ # So, up to now we've been assembling the things that are shared
871+ # between the two queries, but now we start working on the
872+ # differences between the two unioned queries. "first_select"
873+ # will hold one set of arguments, and select_args will hold the
874+ # other.
875+ first_select = select_args.copy()
876+ # As mentioned, in this first SELECT we handle filters that
877+ # match any of the filter's tags. This can be a relatively
878+ # straightforward query--we just need a bit more added to
879+ # our WHERE clause, and we don't need a GROUP BY/HAVING.
880+ first_select['where'] = And(
881+ Or(# We want filters that proclaim they simply want any tags.
882+ BugSubscriptionFilter.include_any_tags,
883+ # Also include filters that match any tag...
884+ And(Not(BugSubscriptionFilter.find_all_tags),
885+ Or(# ...with a positive match...
886+ And(BugSubscriptionFilterTag.include,
887+ In(BugSubscriptionFilterTag.tag, tags)),
888+ # ...or with a negative match...
889+ And(Not(BugSubscriptionFilterTag.include),
890+ Not(In(BugSubscriptionFilterTag.tag, tags))),
891+ # ...or if the filter does not specify any tags.
892+ BugSubscriptionFilterTag.tag == None))),
893+ *conditions)
894+ first_select = Select(BugSubscriptionFilter.id, **first_select)
895+ # We have our first clause. Now we start on the second one:
896+ # handling filters that match *all* tags. Our WHERE clause
897+ # is straightforward and, it should be clear that we are
898+ # simply focusing on BugSubscriptionFilter.find_all_tags,
899+ # when the first SELECT did not consider it.
900+ select_args['where'] = And(
901+ BugSubscriptionFilter.find_all_tags, *conditions)
902+ # The GROUP BY collects the filters together.
903+ select_args['group_by'] = (BugSubscriptionFilter.id,)
904+ # Now it is time for the HAVING clause, which is where some
905+ # tricky bits happen. We first make a SQL snippet that
906+ # represents the tags on this bug. It is straightforward
907+ # except for one subtle hack: the addition of the empty
908+ # space in the array. This is because we are going to be
909+ # aggregating the tags on the filters using ARRAY_AGG, which
910+ # includes NULLs (unlike most other aggregators). That
911+ # is an issue here because we use CASE statements to divide
912+ # up the set of tags that are supposed to be included and
913+ # supposed to be excluded. This means that if we aggregate
914+ # "CASE WHEN BugSubscriptionFilterTag.include THEN
915+ # BugSubscriptionFilterTag.tag END" then that array will
916+ # include NULL. SQL treats NULLs as unknowns that can never
917+ # be matched, so the array of ['foo', 'bar', NULL] does not
918+ # contain the array of ['foo', NULL] ("SELECT
919+ # ARRAY['foo','bar',NULL]::TEXT[] @>
920+ # ARRAY['foo',NULL]::TEXT[];" is false). Therefore, so we
921+ # can make the HAVING statement we want to make without
922+ # defining a custom Postgres aggregator, we use a single
923+ # space as, effectively, NULL. This is safe because a
924+ # single space is not an acceptable tag. Again, the
925+ # clearest alternative is defining a custom Postgres aggregator.
926+ tags_array = "ARRAY[%s,' ']::TEXT[]" % ",".join(
927+ quote(tag) for tag in tags)
928+ # Now comes the HAVING clause itself.
929+ select_args['having'] = And(
930+ # The list of tags should be a superset of the filter tags to
931+ # be included.
932+ ArrayContains(
933+ SQL(tags_array),
934+ # This next line gives us an array of the tags that the
935+ # filter wants to include. Notice that it includes the
936+ # empty string when the condition does not match, per the
937+ # discussion above.
938+ ArrayAgg(
939+ SQL("CASE WHEN BugSubscriptionFilterTag.include "
940+ "THEN BugSubscriptionFilterTag.tag "
941+ "ELSE ' '::TEXT END"))),
942+ # The list of tags should also not intersect with the
943+ # tags that the filter wants to exclude.
944+ Not(
945+ ArrayIntersects(
946+ SQL(tags_array),
947+ # This next line gives us an array of the tags
948+ # that the filter wants to exclude. We do not bother
949+ # with the empty string, and therefore allow NULLs
950+ # into the array, because in this case we are
951+ # determining whether the sets intersect, not if the
952+ # first set subsumes the second.
953+ ArrayAgg(
954+ SQL('CASE WHEN '
955+ 'NOT BugSubscriptionFilterTag.include '
956+ 'THEN BugSubscriptionFilterTag.tag END')))))
957+ # Everything is ready. Make our second SELECT statement, UNION
958+ # it, and return it.
959+ return candidates, Union(
960+ first_select,
961+ Select(
962+ BugSubscriptionFilter.id,
963+ **select_args))
964
965=== modified file 'lib/lp/bugs/model/tests/test_bugtask.py'
966--- lib/lp/bugs/model/tests/test_bugtask.py 2011-02-22 17:11:10 +0000
967+++ lib/lp/bugs/model/tests/test_bugtask.py 2011-03-04 02:31:59 +0000
968@@ -8,7 +8,10 @@
969 import unittest
970
971 from lazr.lifecycle.snapshot import Snapshot
972-from storm.store import ResultSet
973+from storm.store import (
974+ EmptyResultSet,
975+ ResultSet,
976+ )
977 from testtools.matchers import (
978 Equals,
979 StartsWith,
980@@ -1429,7 +1432,6 @@
981 def test_no_subscriptions(self):
982 subscriptions = self.getAllStructuralSubscriptions(
983 self.bug.bugtasks, self.subscriber)
984- self.assertIsInstance(subscriptions, ResultSet)
985 self.assertEqual([], list(subscriptions))
986
987 def test_one_subscription(self):
988@@ -1505,7 +1507,7 @@
989 # subscribers will be returned by getStructuralSubscribers().
990 product, bug = self.make_product_with_bug()
991 subscribers = self.getStructuralSubscribers(bug.bugtasks)
992- self.assertIsInstance(subscribers, ResultSet)
993+ self.assertIsInstance(subscribers, (ResultSet, EmptyResultSet))
994 self.assertEqual([], list(subscribers))
995
996 def test_getStructuralSubscribers_single_target(self):
997
998=== modified file 'lib/lp/bugs/tests/test_structuralsubscriptiontarget.py'
999--- lib/lp/bugs/tests/test_structuralsubscriptiontarget.py 2011-02-18 11:59:44 +0000
1000+++ lib/lp/bugs/tests/test_structuralsubscriptiontarget.py 2011-03-04 02:31:59 +0000
1001@@ -331,8 +331,21 @@
1002 # Without either tag the subscription is found.
1003 self.assertSubscriptions([self.subscription])
1004
1005- # With either tag the subscription is no longer found.
1006+ # With both tags, the subscription is omitted.
1007+ self.bug.tags = ["foo", "bar"]
1008+ self.assertSubscriptions([])
1009+
1010+ # With only one tag, the subscription is found again.
1011 self.bug.tags = ["foo"]
1012+ self.assertSubscriptions([self.subscription])
1013+
1014+ # However, if find_all_tags is True, even a single excluded tag
1015+ # causes the subscription to be skipped.
1016+ self.initial_filter.find_all_tags = True
1017+ self.assertSubscriptions([])
1018+
1019+ # This is also true, of course, if the bug has both tags.
1020+ self.bug.tags = ["foo", "bar"]
1021 self.assertSubscriptions([])
1022
1023 def test_getSubscriptionsForBugTask_with_filter_for_not_all_tags(self):
1024@@ -347,11 +360,13 @@
1025 # Without either tag the subscription is found.
1026 self.assertSubscriptions([self.subscription])
1027
1028- # With only one of the excluded tags the subscription is found.
1029+ # With only one of the excluded tags the subscription is not
1030+ # found--we are saying that we want to find both an absence of foo
1031+ # and an absence of bar, and yet foo exists.
1032 self.bug.tags = ["foo"]
1033- self.assertSubscriptions([self.subscription])
1034+ self.assertSubscriptions([])
1035
1036- # With both tags the subscription is no longer found.
1037+ # With both tags the subscription is also not found.
1038 self.bug.tags = ["foo", "bar"]
1039 self.assertSubscriptions([])
1040
1041@@ -361,6 +376,7 @@
1042
1043 # Add the "foo" tag to the bug.
1044 self.bug.tags = ["foo"]
1045+ self.assertSubscriptions([self.subscription])
1046
1047 # Filter the subscription to bugs in the CRITICAL state.
1048 self.initial_filter.statuses = [BugTaskStatus.CONFIRMED]
1049@@ -553,7 +569,8 @@
1050 return self.factory.makeMilestone()
1051
1052 def makeBugTask(self):
1053- return self.factory.makeBugTask(target=self.target.series_target)
1054+ bug = self.factory.makeBug(milestone=self.target)
1055+ return bug.bugtasks[0]
1056
1057
1058 class TestStructuralSubscriptionForDistroSeries(