Merge lp:~allenap/launchpad/subscribe-to-tag-bug-151129-2 into lp:launchpad
- subscribe-to-tag-bug-151129-2
- Merge into devel
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 | ||||
Related bugs: |
|
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,
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,
BugFilterSetBui
match. From that it can figure out which subscriptions match.
j.c.sackett (jcsackett) wrote : | # |
Gavin Panella (allenap) wrote : | # |
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 structuralsubsc
> 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_
> > + BugSubscription
> > + self.base_
> > + else:
> > + self.filter_
> > + BugSubscription
> > + self.base_
> > +
>
> A quick comment here explaining the logic of setting up exclude_any vs
> include_any would be good.
Done.
...
> > + def _filters_
> > + # 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_
> > + Alias(
> > + BugSubscription
> > + "structural_
> > + BugSubscription
> > + tables=(
> > + StructuralSubsc
> > + where=And(
> > + BugSubscription
> > + StructuralSubsc
> > + self.filter_
> > + 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_
> > + tags_array = "ARRAY[%s]::TEXT[]" % ",".join(
> > + quote(tag) for tag in self.tags)
> > + return self._filters_
> > + BugSubscription
> > + And(
> > + BugSubscription
> > + BugSubscription
> > + BugSubscription
> > + self.filter_
> > + where_condition),
> > + group_by=(
> > + ...
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 BugFilterSetBiu
j.c.sackett (jcsackett) wrote : | # |
Gavin--
I like those changes. Thanks!
Preview Diff
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): |
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 structuralsubsc ription. 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/structura lsubscription. py' registry/ model/structura lsubscription. py 2010-11-18 16:11:43 +0000 registry/ model/structura lsubscription. py 2010-11-18 16:11:51 +0000 sForBugTask( self, bugtask, level): scriptionTarget `.""" ription, Filter, Filter. structural_ subscription_ id == ( ription. id)), FilterStatus, FilterStatus. filter_ id == ( Filter. id)), FilterImportanc e, FilterImportanc e.filter_ id == ( Filter. id)), FilterTag " Filter. id AND include") is_empty = SQL( FilterTag " Filter. id AND NOT include") is_empty = SQL( all_combinator( find_all_ expr, find_any_expr): Filter. find_all_ tags " bug.tags) == 0: Filter. include_ any_tags == False, is_empty,
> --- lib/lp/
> +++ lib/lp/
> def getSubscription
> """See `IStructuralSub
> - origin = [
> - StructuralSubsc
> - LeftJoin(
> - BugSubscription
> - BugSubscription
> - StructuralSubsc
> - LeftJoin(
> - BugSubscription
> - BugSubscription
> - BugSubscription
> - LeftJoin(
> - BugSubscription
> - BugSubscription
> - BugSubscription
> - ]
> -
> - # 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 BugSubscription
> - "WHERE filter = BugSubscription
> - tags_include_array = "ARRAY(%s)" % tags_include_expr
> - tags_include_
> - "ARRAY[]::TEXT[] = %s" % tags_include_array)
> -
> - # The tags a subscription requests for exclusion.
> - tags_exclude_expr = (
> - "SELECT tag FROM BugSubscription
> - "WHERE filter = BugSubscription
> - tags_exclude_array = "ARRAY(%s)" % tags_exclude_expr
> - tags_exclude_
> - "ARRAY[]::TEXT[] = %s" % tags_exclude_array)
> -
> - # Choose the correct expression depending on the find_all_tags flag.
> - def tags_find_
> - return SQL(
> - "CASE WHEN BugSubscription
> - "THEN (%s) ELSE (%s) END" % (find_all_expr, find_any_expr))
> -
> - if len(bugtask.
> - tag_conditions = [
> - BugSubscription
> - # The subscription's required tags must be an empty set.
> - tags_include_
> - # The subscription's excluded tags can be anything so no
> - # condition...