Merge lp:~adeuring/launchpad/bug-739052 into lp:launchpad

Proposed by Abel Deuring
Status: Merged
Approved by: Abel Deuring
Approved revision: no longer in the source branch.
Merged at revision: 13587
Proposed branch: lp:~adeuring/launchpad/bug-739052
Merge into: lp:launchpad
Diff against target: 798 lines (+702/-16)
6 files modified
lib/canonical/launchpad/components/decoratedresultset.py (+23/-1)
lib/canonical/launchpad/components/tests/decoratedresultset.txt (+16/-0)
lib/canonical/launchpad/webapp/batching.py (+233/-2)
lib/canonical/launchpad/webapp/interfaces.py (+6/-0)
lib/canonical/launchpad/webapp/tests/test_batching.py (+423/-12)
lib/canonical/launchpad/zcml/decoratedresultset.zcml (+1/-1)
To merge this branch: bzr merge lp:~adeuring/launchpad/bug-739052
Reviewer Review Type Date Requested Status
Robert Collins (community) Approve
Review via email: mp+69625@code.launchpad.net

Commit message

[r=lifeless][no-qa] new class StormRangeFactory

Description of the change

This branch adds a class StormRangeFactory, which implements
the IRangeFactory interface. It works for Storm result sets,
and allows to retrieve batches as suggested by Jeroen some
months ago: Instead of an OFFSET clause it uses WHERE
expressions. The WHERE expressions are generated from the
sort expressions of the result set.

Limitations:
  - The class works only for WHERE expressions like Person.id
    or Desc(Person.id). In other words, it assumes that the
    order expression is a Storm PropertyColumn. PropertyColumns
    are Python descriptors, i.e., they provide the methods
    __get__(), __set__(), __delete__(). They allow a usage like

      Person.id.__get__(instance_of_Person)

    where resultset[0] is an instance of class Person.

    Sort expressions like

      resultset.order_by('Person.id')

    or

      resultset.order_by(Person.displayname + Person.name)

    are not supported.

  - The query must return instances of SQLBase-derived classes.
    In other words, a query like

       store.find((Person.id, Person.name))

    is not supported by StormRangeFactory. (A DecoratedResultSet
    should be used in this case.)

The basic concept:

A StormRangeFactory is intended to be used by a
lazr.batchnavigator.BatchNavigator to retrieve a slice of a Storm
result set.

BatchNavigator calls range_factory.getEndpointMemos() to get
parameters which describe how to retrieve the previous or next
batch. These values are use for "memo" parameter in URLs for
the previous/next batch.

StormRangeFactory.getEndpointMemos() returns a JSON representation
of the values of the sort columns of the first and last element
of the current batch.

This JSON representation is used in StormRangeFactory.getSlice()
to generate WHERE expressions.

This branch does a few no-nos:

  - it imports PropertyColumn from storm.properties, but this
    class does not appear in the module's __all__ list.
    This import is not strictly necessary, but it makes the
    detection possible usage problems much easier.
  - it uses storm.store.ResultSet.find(), but this method is not
    declared in IResultSet.

I suspected such problems when I started this branch; lifeless
suggested to start nevertheless -- we could submit patches to
Storm if this work turn out to be useful.

The new method DecoratedResultSet.find() looks a bit convoluted
with its security proxy treatment. This is necessary because
we use this class on both sides of the "security fence":
In model code as well as in browser code.

If a DecoratedResultSet is created in browser code, we need
to remove the security proxy of the plain result set in order
to call its method find(), but the new result set should
again be security proxied.

But if a DecoratedResultSet is created in model code, its
decorator can access "forbidden" attributes, so we cannot
store a proxied Storm ResultSet as self.result_set of the
new DecoratedResultSet generated by find(). An example is
the DecoratedResultSet returned by Bug.attachments: its
decorator (set_indexed_message()) accesses
attachment._messageID.

I "recycled" the file lib/canonical/launchpad/webapp/tests/test_batching.py:
It did not run any doc tests, so I simply used it for the unit
tests.

tests:
./bin/test canonical -vvt canonical.launchpad.webapp.tests.test_batching
./bin/test canonical -vvt lib/canonical/launchpad/components/tests/decoratedresultset.txt

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/canonical/launchpad/components/decoratedresultset.py
  lib/canonical/launchpad/components/tests/decoratedresultset.txt
  lib/canonical/launchpad/webapp/batching.py
  lib/canonical/launchpad/webapp/tests/test_batching.py

./lib/canonical/launchpad/components/tests/decoratedresultset.txt
       1: narrative uses a moin header.
      33: narrative uses a moin header.
      56: narrative uses a moin header.
      74: narrative uses a moin header.
      82: narrative uses a moin header.
      91: narrative uses a moin header.
      99: narrative uses a moin header.
     107: narrative uses a moin header.
     121: narrative uses a moin header.
     132: narrative uses a moin header.
     139: narrative uses a moin header.

well... the diff for this branch is already 799 lines long, so I
did not remove the lint...

To post a comment you must log in.
Revision history for this message
Robert Collins (lifeless) wrote :

 have a functional suggestion for you

currently you have some limits about requiring the thing being sorted on to be in the result set.
But resultset.find(resultset._order_by) will return those expressions (except for desc).

You could handle DecoratedResultSet by doing this:
resultset.find(original_find_expr + _order_by_after_stripping_desc) and creating a lambda to strip the extra results off, then feed them back into the original decoratedresultset row / resultset callbacks.

This could be done in a follow on patch of course.

The following are things I noted reading the patch, I don't think any are mandatory, but they are all pretty shallow so please give them your consideration.

DecoratedResultSet uses pep8 - foo_bar not fooBar, so you could follow its idiom there.

You can add find() to IResultSet via zcml magic if you want; personally I'd fixup storm directly (but after we have this working :)).

DateTimeJSONEncoder might want to live somewhere in the lazr.restful space I guess? What does lazr.restful do for datetime objects? I bet there is an existing encoder.

A decoratedresultset(decoratedresultset(resultset)) will barf atm - may want to loop in either the caller, or the get_plain_resultset method.

I would personally call getSortExpressions getOrderBy - given thats what it docstring says it does.

StormRangeFactory supports only sorting by
would be better english as
StormRangeFactory only supports sorting by

You may need
300 + if isinstance(expression, Desc):
301 + expression = expression.expr
302 + return expression < memo
303 + else:
304 + return expression > memo

to have >= instead of > - I'm not sure about this, but a collection sorted on two keys - say name, someint with rows ('foo', 1) ('foo', 2) ('foo', 3) ... would be a way to test this - make sure that pages work properly.

subclassing assertionerror - StormRangeFactoryError(AssertionError) - is a bit ugly, better to subclass Exception directly

typo: Pytjon

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

Hi Robert,

thanks for the review and many goos suggestions.

On 29.07.2011 10:42, Robert Collins wrote:
> Review: Approve
> have a functional suggestion for you
[...]
>
> You may need
> 300 + if isinstance(expression, Desc):
> 301 + expression = expression.expr
> 302 + return expression < memo
> 303 + else:
> 304 + return expression > memo
>
> to have >= instead of > - I'm not sure about this, but a collection
sorted on two keys - say name, someint with rows ('foo', 1) ('foo', 2)
('foo', 3) ... would be a way to test this - make sure that pages work
properly.

Good catch!

Ordering by more that one column makes the approach indeed a bit more
complicated....

We don't want the row with the memo values itself in the result, so, for
a sort expression like "ORDER BY col1, col2, col3" we can use

  SELECT ... FROM ...
  WHERE (original_clause AND col1 >= memo1 AND col2 >= memo2
         AND col3 >= memo3) OFFSET 1

An ugly alternative:

   WHERE (original_clause) AND col1 > memo1
         OR (col1 = memo1 AND col2 > memo2)
         OR (col1 = memo1 AND col2 = memo2 AND col3 > memo3)

Or, since the three WHERE clauses do not overlap:

   (SELECT ... FROM ... WHERE original_clause
       AND col1 = memo1 AND col2 = memo2 AND col3 > memo3 ORDER BY col3)
   UNION ALL
   (SELECT ... FROM ... WHERE original_clause
       AND col1 = memo1 AND col2 > memo2 ORDER BY col2, col3)
   UNION ALL
   (SELECT ... FROM ... WHERE original_clause
   AND col1 > memo1 ORDER BY col1, col2, col3)

that's even more ugly...

Revision history for this message
Robert Collins (lifeless) wrote :

I think

  SELECT ... FROM ...
  WHERE (original_clause AND col1 >= memo1 AND col2 >= memo2
         AND col3 > memo3)

is whats needed ?

Revision history for this message
Robert Collins (lifeless) wrote :

nvm I see - col2 should be bound differently for col1==memo1.

However
(col1, col2, col3) >= (memo1, memo2, memo3)

should work ?

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

On 02.08.2011 11:01, Robert Collins wrote:
> I think
>
>
> SELECT ... FROM ...
> WHERE (original_clause AND col1 >= memo1 AND col2 >= memo2
> AND col3 > memo3)
>
> is whats needed ?
>

no, lets assume two sort columns, with these values in the result set:

1, 1
1, 2
1, 3
2, 1
2, 2
2, 3

If the memo value is (1, 2), we want the rows starting at (1, 3).

So we need the rows where col1 == memo1 and col2 > memo2, and
additinally the rows where col1 > memo1.

See also
https://code.launchpad.net/~adeuring/launchpad/bug-739052-2/+merge/70044

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/canonical/launchpad/components/decoratedresultset.py'
--- lib/canonical/launchpad/components/decoratedresultset.py 2011-05-03 07:10:37 +0000
+++ lib/canonical/launchpad/components/decoratedresultset.py 2011-07-28 11:31:31 +0000
@@ -11,7 +11,10 @@
11from lazr.delegates import delegates11from lazr.delegates import delegates
12from storm import Undef12from storm import Undef
13from storm.zope.interfaces import IResultSet13from storm.zope.interfaces import IResultSet
14from zope.security.proxy import removeSecurityProxy14from zope.security.proxy import (
15 ProxyFactory,
16 removeSecurityProxy,
17 )
1518
1619
17class DecoratedResultSet(object):20class DecoratedResultSet(object):
@@ -170,3 +173,22 @@
170 return DecoratedResultSet(173 return DecoratedResultSet(
171 new_result_set, self.result_decorator, self.pre_iter_hook,174 new_result_set, self.result_decorator, self.pre_iter_hook,
172 self.slice_info)175 self.slice_info)
176
177 def getPlainResultSet(self):
178 """Return the plain Storm result set."""
179 return self.result_set
180
181 def find(self, *args, **kwargs):
182 """See `IResultSet`.
183
184 :return: The decorated version of the returned result set.
185 """
186 naked_result_set = removeSecurityProxy(self.result_set)
187 if naked_result_set is not self.result_set:
188 naked_new_result_set = naked_result_set.find(*args, **kwargs)
189 new_result_set = ProxyFactory(naked_new_result_set)
190 else:
191 new_result_set = self.result_set.find(*args, **kwargs)
192 return DecoratedResultSet(
193 new_result_set, self.result_decorator, self.pre_iter_hook,
194 self.slice_info)
173195
=== modified file 'lib/canonical/launchpad/components/tests/decoratedresultset.txt'
--- lib/canonical/launchpad/components/tests/decoratedresultset.txt 2010-10-18 22:24:59 +0000
+++ lib/canonical/launchpad/components/tests/decoratedresultset.txt 2011-07-28 11:31:31 +0000
@@ -136,3 +136,19 @@
136 >>> isinstance(decorated_result_set[0:3], DecoratedResultSet)136 >>> isinstance(decorated_result_set[0:3], DecoratedResultSet)
137 True137 True
138138
139== find() ==
140
141DecoratedResultSet.find() returns another DecoratedResultSet containing
142a refined query.
143
144 >>> result_set = store.find(Distribution)
145 >>> proxied_result_set = ProxyFactory(result_set)
146 >>> decorated_result_set = DecoratedResultSet(
147 ... proxied_result_set, result_decorator)
148 >>> ubuntu_distros = removeSecurityProxy(decorated_result_set).find(
149 ... "Distribution.name like 'ubuntu%'")
150 >>> for dist in ubuntu_distros:
151 ... dist
152 u'Dist name is: ubuntu'
153 u'Dist name is: ubuntutest'
154
139155
=== modified file 'lib/canonical/launchpad/webapp/batching.py'
--- lib/canonical/launchpad/webapp/batching.py 2011-07-13 06:17:19 +0000
+++ lib/canonical/launchpad/webapp/batching.py 2011-07-28 11:31:31 +0000
@@ -3,20 +3,38 @@
33
4__metaclass__ = type4__metaclass__ = type
55
6from datetime import datetime
7
6import lazr.batchnavigator8import lazr.batchnavigator
9from lazr.batchnavigator.interfaces import IRangeFactory
10import simplejson
11from storm import Undef
12from storm.expr import Desc
13from storm.properties import PropertyColumn
7from storm.zope.interfaces import IResultSet14from storm.zope.interfaces import IResultSet
8from zope.component import adapts15from zope.component import adapts
9from zope.interface import implements16from zope.interface import implements
10from zope.interface.common.sequence import IFiniteSequence17from zope.interface.common.sequence import IFiniteSequence
18from zope.security.proxy import (
19 isinstance as zope_isinstance,
20 ProxyFactory,
21 removeSecurityProxy,
22 )
1123
12from canonical.config import config24from canonical.config import config
13from canonical.launchpad.webapp.interfaces import ITableBatchNavigator25from canonical.launchpad.components.decoratedresultset import (
26 DecoratedResultSet,
27 )
28from canonical.launchpad.webapp.interfaces import (
29 ITableBatchNavigator,
30 StormRangeFactoryError,
31 )
14from canonical.launchpad.webapp.publisher import LaunchpadView32from canonical.launchpad.webapp.publisher import LaunchpadView
1533
1634
17class FiniteSequenceAdapter:35class FiniteSequenceAdapter:
1836
19 adapts(IResultSet) # and ISQLObjectResultSet37 adapts(IResultSet)
20 implements(IFiniteSequence)38 implements(IFiniteSequence)
2139
22 def __init__(self, context):40 def __init__(self, context):
@@ -123,3 +141,216 @@
123 if columns_to_show:141 if columns_to_show:
124 for column_to_show in columns_to_show:142 for column_to_show in columns_to_show:
125 self.show_column[column_to_show] = True143 self.show_column[column_to_show] = True
144
145
146class DateTimeJSONEncoder(simplejson.JSONEncoder):
147 """A JSON encoder that understands datetime objects.
148
149 Datetime objects are formatted according to ISO 1601.
150 """
151 def default(self, obj):
152 if isinstance(obj, datetime):
153 return obj.isoformat()
154 return simplejson.JSONEncoder.default(self, obj)
155
156
157class StormRangeFactory:
158 """A range factory for Storm result sets.
159
160 It creates the endpoint memo values from the expressions used in the
161 ORDER BY clause.
162
163 Limitations:
164
165 - The order_by expressions must be Storm PropertyColumn instances,
166 e.g. Bug.title. Simple strings (e.g., resultset.order_by('Bug.id')
167 or general Storm SQL expression are not supported.
168 - The objects representing rows of the PropertyColumn's table must
169 be contained in the result. I.e.,
170
171 store.find(Bug.id, Bug.id < 10)
172
173 does not work, while
174
175 store.find(Bug, Bug.id < 10)
176
177 works.
178 """
179 implements(IRangeFactory)
180
181 def __init__(self, resultset, error_cb=None):
182 """Create a new StormRangeFactory instance.
183
184 :param resultset: A Storm ResultSet instance or a DecoratedResultSet
185 instance.
186 :param error_cb: A function which takes one string as a parameter.
187 It is called when the parameter endpoint_memo of getSlice()
188 does not match the order settings of a resultset.
189 """
190 self.resultset = resultset
191 if zope_isinstance(resultset, DecoratedResultSet):
192 self.plain_resultset = resultset.getPlainResultSet()
193 else:
194 self.plain_resultset = resultset
195 self.error_cb = error_cb
196
197 def getSortExpressions(self):
198 """Return the order_by expressions of the result set."""
199 return removeSecurityProxy(self.plain_resultset)._order_by
200
201 def getOrderValuesFor(self, row):
202 """Return the values of the order_by expressions for the given row.
203 """
204 sort_values = []
205 if not zope_isinstance(row, tuple):
206 row = (row, )
207 sort_expressions = self.getSortExpressions()
208 if sort_expressions is Undef:
209 raise StormRangeFactoryError(
210 'StormRangeFactory requires a sorted result set.')
211 for expression in sort_expressions:
212 if zope_isinstance(expression, Desc):
213 expression = expression.expr
214 if not zope_isinstance(expression, PropertyColumn):
215 raise StormRangeFactoryError(
216 'StormRangeFactory supports only sorting by '
217 'PropertyColumn, not by %r.' % expression)
218 class_instance_found = False
219 for row_part in row:
220 if zope_isinstance(row_part, expression.cls):
221 sort_values.append(expression.__get__(row_part))
222 class_instance_found = True
223 break
224 if not class_instance_found:
225 raise StormRangeFactoryError(
226 'Instances of %r are not contained in the result set, '
227 'but are required to retrieve the value of %s.%s.'
228 % (expression.cls, expression.cls.__name__,
229 expression.name))
230 return sort_values
231
232 def getEndpointMemos(self, batch):
233 """See `IRangeFactory`."""
234 lower = self.getOrderValuesFor(self.plain_resultset[0])
235 upper = self.getOrderValuesFor(
236 self.plain_resultset[batch.trueSize - 1])
237 return (
238 simplejson.dumps(lower, cls=DateTimeJSONEncoder),
239 simplejson.dumps(upper, cls=DateTimeJSONEncoder),
240 )
241
242 def reportError(self, message):
243 if self.error_cb is not None:
244 self.error_cb(message)
245
246 def parseMemo(self, memo):
247 """Convert the given memo string into a sequence of Python objects.
248
249 memo should be a JSON string as returned by getEndpointMemos().
250
251 Note that memo originates from a URL query parameter and can thus
252 not be trusted to always contain formally valid and consistent
253 data.
254
255 Parsing errors or data not matching the sort parameters of the
256 result set are simply ignored.
257 """
258 if memo == '':
259 return None
260 try:
261 parsed_memo = simplejson.loads(memo)
262 except simplejson.JSONDecodeError:
263 self.reportError('memo is not a valid JSON string.')
264 return None
265 if not isinstance(parsed_memo, list):
266 self.reportError(
267 'memo must be the JSON representation of a list.')
268 return None
269
270 sort_expressions = self.getSortExpressions()
271 if len(sort_expressions) != len(parsed_memo):
272 self.reportError(
273 'Invalid number of elements in memo string. '
274 'Expected: %i, got: %i'
275 % (len(sort_expressions), len(parsed_memo)))
276 return None
277
278 converted_memo = []
279 for expression, value in zip(sort_expressions, parsed_memo):
280 if isinstance(expression, Desc):
281 expression = expression.expr
282 try:
283 expression.variable_factory(value=value)
284 except TypeError, error:
285 # A TypeError is raised when the type of value cannot
286 # be used for expression. All expected types are
287 # properly created by simplejson.loads() above, except
288 # time stamps which are represented as strings in
289 # ISO format. If value is a string and if it can be
290 # converted into a datetime object, we have a valid
291 # value.
292 if (str(error).startswith('Expected datetime') and
293 isinstance(value, str)):
294 try:
295 value = datetime.strptime(
296 value, '%Y-%m-%dT%H:%M:%S.%f')
297 except ValueError:
298 # One more attempt: If the fractions of a second
299 # are zero, datetime.isoformat() omits the
300 # entire part '.000000', so we need a different
301 # format for strptime().
302 try:
303 value = datetime.strptime(
304 value, '%Y-%m-%dT%H:%M:%S')
305 except ValueError:
306 self.reportError(
307 'Invalid datetime value: %r' % value)
308 return None
309 else:
310 self.reportError(
311 'Invalid parameter: %r' % value)
312 return None
313 converted_memo.append(value)
314 return converted_memo
315
316 def reverseSortOrder(self):
317 """Return a list of reversed sort expressions."""
318 def invert_sort_expression(expr):
319 if isinstance(expression, Desc):
320 return expression.expr
321 else:
322 return Desc(expression)
323
324 return [
325 invert_sort_expression(expression)
326 for expression in self.getSortExpressions()]
327
328 def whereExpressionFromSortExpression(self, expression, memo):
329 """Create a Storm expression to be used in the WHERE clause of the
330 slice query.
331 """
332 if isinstance(expression, Desc):
333 expression = expression.expr
334 return expression < memo
335 else:
336 return expression > memo
337
338 def getSlice(self, size, endpoint_memo='', forwards=True):
339 """See `IRangeFactory`."""
340 if not forwards:
341 self.resultset.order_by(*self.reverseSortOrder())
342 parsed_memo = self.parseMemo(endpoint_memo)
343 if parsed_memo is None:
344 return self.resultset.config(limit=size)
345 else:
346 sort_expressions = self.getSortExpressions()
347 where = [
348 self.whereExpressionFromSortExpression(expression, memo)
349 for expression, memo in zip(sort_expressions, parsed_memo)]
350 # From storm.zope.interfaces.IResultSet.__doc__:
351 # - C{find()}, C{group_by()} and C{having()} are really
352 # used to configure result sets, so are mostly intended
353 # for use on the model side.
354 naked_result = removeSecurityProxy(self.resultset).find(*where)
355 result = ProxyFactory(naked_result)
356 return result.config(limit=size)
126357
=== modified file 'lib/canonical/launchpad/webapp/interfaces.py'
--- lib/canonical/launchpad/webapp/interfaces.py 2011-06-14 15:03:56 +0000
+++ lib/canonical/launchpad/webapp/interfaces.py 2011-07-28 11:31:31 +0000
@@ -878,3 +878,9 @@
878878
879 def __init__(self, request):879 def __init__(self, request):
880 self.request = request880 self.request = request
881
882
883class StormRangeFactoryError(AssertionError):
884 """Raised when a Storm result set cannot be used for slicing by a
885 StormRangeFactory.
886 """
881887
=== modified file 'lib/canonical/launchpad/webapp/tests/test_batching.py'
--- lib/canonical/launchpad/webapp/tests/test_batching.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/webapp/tests/test_batching.py 2011-07-28 11:31:31 +0000
@@ -1,19 +1,430 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the1# Copyright 2011 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
55
6from doctest import (6from datetime import datetime
7 DocTestSuite,7import simplejson
8 ELLIPSIS,8from unittest import TestLoader
9 NORMALIZE_WHITESPACE,9
10 )10from lazr.batchnavigator.interfaces import IRangeFactory
11from storm.expr import (
12 Desc,
13 Gt,
14 Lt,
15 )
16from storm.variables import IntVariable
17from zope.security.proxy import isinstance as zope_isinstance
18
19from canonical.launchpad.components.decoratedresultset import (
20 DecoratedResultSet,
21 )
22from canonical.launchpad.database.librarian import LibraryFileAlias
23from canonical.launchpad.webapp.batching import (
24 BatchNavigator,
25 DateTimeJSONEncoder,
26 StormRangeFactory,
27 )
28from canonical.launchpad.webapp.interfaces import StormRangeFactoryError
29from canonical.launchpad.webapp.servers import LaunchpadTestRequest
30from canonical.launchpad.webapp.testing import verifyObject
31from canonical.testing.layers import LaunchpadFunctionalLayer
32from lp.registry.model.person import Person
33from lp.testing import (
34 TestCaseWithFactory,
35 person_logged_in,
36 )
37
38
39class TestStormRangeFactory(TestCaseWithFactory):
40 """Tests for StormRangeFactory."""
41
42 layer = LaunchpadFunctionalLayer
43
44 def setUp(self):
45 super(TestStormRangeFactory, self).setUp()
46 self.error_messages = []
47
48 def makeStormResultSet(self):
49 bug = self.factory.makeBug()
50 for count in range(5):
51 person = self.factory.makePerson()
52 with person_logged_in(person):
53 bug.markUserAffected(person, True)
54 return bug.users_affected
55
56 def makeDecoratedStormResultSet(self):
57 bug = self.factory.makeBug()
58 with person_logged_in(bug.owner):
59 for count in range(5):
60 self.factory.makeBugAttachment(bug=bug, owner=bug.owner)
61 result = bug.attachments
62 self.assertTrue(zope_isinstance(result, DecoratedResultSet))
63 return result
64
65 def test_StormRangeFactory_implements_IRangeFactory(self):
66 resultset = self.makeStormResultSet()
67 range_factory = StormRangeFactory(resultset)
68 self.assertTrue(verifyObject(IRangeFactory, range_factory))
69
70 def test_getOrderValuesFor__one_sort_column(self):
71 # StormRangeFactory.getOrderValuesFor() returns the values
72 # of the fields used in order_by expresssions for a given
73 # result row.
74 resultset = self.makeStormResultSet()
75 resultset.order_by(Person.id)
76 range_factory = StormRangeFactory(resultset)
77 order_values = range_factory.getOrderValuesFor(resultset[0])
78 self.assertEqual([resultset[0].id], order_values)
79
80 def test_getOrderValuesFor__two_sort_columns(self):
81 # Sorting by more than one column is supported.
82 resultset = self.makeStormResultSet()
83 resultset.order_by(Person.displayname, Person.name)
84 range_factory = StormRangeFactory(resultset)
85 order_values = range_factory.getOrderValuesFor(resultset[0])
86 self.assertEqual(
87 [resultset[0].displayname, resultset[0].name], order_values)
88
89 def test_getOrderValuesFor__string_as_sort_expression(self):
90 # Sorting by a string expression is not supported.
91 resultset = self.makeStormResultSet()
92 resultset.order_by('Person.id')
93 range_factory = StormRangeFactory(resultset)
94 exception = self.assertRaises(
95 StormRangeFactoryError, range_factory.getOrderValuesFor,
96 resultset[0])
97 self.assertEqual(
98 "StormRangeFactory supports only sorting by PropertyColumn, "
99 "not by 'Person.id'.",
100 str(exception))
101
102 def test_getOrderValuesFor__generic_storm_expression_as_sort_expr(self):
103 # Sorting by a generic Strom expression is not supported.
104 resultset = self.makeStormResultSet()
105 range_factory = StormRangeFactory(resultset)
106 exception = self.assertRaises(
107 StormRangeFactoryError, range_factory.getOrderValuesFor,
108 resultset[0])
109 self.assertTrue(
110 str(exception).startswith(
111 'StormRangeFactory supports only sorting by PropertyColumn, '
112 'not by <storm.expr.SQL object at'))
113
114 def test_getOrderValuesFor__unordered_result_set(self):
115 # If a result set is not ordered, it cannot be used with a
116 # StormRangeFactory.
117 resultset = self.makeStormResultSet()
118 resultset.order_by()
119 range_factory = StormRangeFactory(resultset)
120 exception = self.assertRaises(
121 StormRangeFactoryError, range_factory.getOrderValuesFor,
122 resultset[0])
123 self.assertEqual(
124 "StormRangeFactory requires a sorted result set.",
125 str(exception))
126
127 def test_getOrderValuesFor__decorated_result_set(self):
128 # getOrderValuesFor() knows how to retrieve SQL sort values
129 # from DecoratedResultSets.
130 resultset = self.makeDecoratedStormResultSet()
131 range_factory = StormRangeFactory(resultset)
132 self.assertEqual(
133 [resultset[0].id], range_factory.getOrderValuesFor(resultset[0]))
134
135 def test_getOrderValuesFor__value_from_second_element_of_result_row(self):
136 # getOrderValuesFor() can retrieve values from attributes
137 # of any Storm table class instance which appear in a result row.
138 resultset = self.makeDecoratedStormResultSet()
139 resultset = resultset.order_by(LibraryFileAlias.id)
140 plain_resultset = resultset.getPlainResultSet()
141 range_factory = StormRangeFactory(resultset)
142 self.assertEqual(
143 [plain_resultset[0][1].id],
144 range_factory.getOrderValuesFor(plain_resultset[0]))
145
146 def test_getOrderValuesFor__descending_sort_order(self):
147 # getOrderValuesFor() can retrieve values from reverse sorted
148 # columns.
149 resultset = self.makeStormResultSet()
150 resultset = resultset.order_by(Desc(Person.id))
151 range_factory = StormRangeFactory(resultset)
152 self.assertEqual(
153 [resultset[0].id], range_factory.getOrderValuesFor(resultset[0]))
154
155 def test_getOrderValuesFor__table_not_included_in_results(self):
156 # Attempts to use a sort by a column which does not appear in the
157 # data returned by the query raise a StormRangeFactoryError.
158 resultset = self.makeStormResultSet()
159 resultset.order_by(LibraryFileAlias.id)
160 range_factory = StormRangeFactory(resultset)
161 exception = self.assertRaises(
162 StormRangeFactoryError, range_factory.getOrderValuesFor,
163 resultset[0])
164 self.assertEqual(
165 "Instances of <class "
166 "'canonical.launchpad.database.librarian.LibraryFileAlias'> are "
167 "not contained in the result set, but are required to retrieve "
168 "the value of LibraryFileAlias.id.",
169 str(exception))
170
171 def test_DatetimeJSONEncoder(self):
172 # DateTimeJSONEncoder represents Pytjon datetime objects as strings
173 # where the value is represented in the ISO time format.
174 self.assertEqual(
175 '"2011-07-25T00:00:00"',
176 simplejson.dumps(datetime(2011, 7, 25), cls=DateTimeJSONEncoder))
177
178 # DateTimeJSONEncoder works for the regular Python types that can
179 # represented as JSON strings.
180 encoded = simplejson.dumps(
181 ('foo', 1, 2.0, [3, 4], {5: 'bar'}, datetime(2011, 7, 24)),
182 cls=DateTimeJSONEncoder)
183 self.assertEqual(
184 '["foo", 1, 2.0, [3, 4], {"5": "bar"}, "2011-07-24T00:00:00"]',
185 encoded
186 )
187
188 def test_getEndpointMemos(self):
189 # getEndpointMemos() returns JSON representations of the
190 # sort fields of the first and last element of a batch.
191 resultset = self.makeStormResultSet()
192 resultset.order_by(Person.name)
193 request = LaunchpadTestRequest()
194 batchnav = BatchNavigator(
195 resultset, request, size=3, range_factory=StormRangeFactory)
196 range_factory = StormRangeFactory(resultset)
197 first, last = range_factory.getEndpointMemos(batchnav.batch)
198 expected_first = simplejson.dumps(
199 [resultset[0].name], cls=DateTimeJSONEncoder)
200 expected_last = simplejson.dumps(
201 [resultset[2].name], cls=DateTimeJSONEncoder)
202 self.assertEqual(expected_first, first)
203 self.assertEqual(expected_last, last)
204
205 def test_getEndpointMemos__decorated_result_set(self):
206 # getEndpointMemos() works for DecoratedResultSet
207 # instances too.
208 resultset = self.makeDecoratedStormResultSet()
209 resultset.order_by(LibraryFileAlias.id)
210 request = LaunchpadTestRequest()
211 batchnav = BatchNavigator(
212 resultset, request, size=3, range_factory=StormRangeFactory)
213 range_factory = StormRangeFactory(resultset)
214 first, last = range_factory.getEndpointMemos(batchnav.batch)
215 expected_first = simplejson.dumps(
216 [resultset.getPlainResultSet()[0][1].id], cls=DateTimeJSONEncoder)
217 expected_last = simplejson.dumps(
218 [resultset.getPlainResultSet()[2][1].id],
219 cls=DateTimeJSONEncoder)
220 self.assertEqual(expected_first, first)
221 self.assertEqual(expected_last, last)
222
223 def logError(self, message):
224 # An error callback for StormResultSet.
225 self.error_messages.append(message)
226
227 def test_parseMemo__empty_value(self):
228 # parseMemo() returns None for an empty memo value.
229 resultset = self.makeStormResultSet()
230 range_factory = StormRangeFactory(resultset, self.logError)
231 self.assertIs(None, range_factory.parseMemo(''))
232 self.assertEqual(0, len(self.error_messages))
233
234 def test_parseMemo__json_error(self):
235 # parseMemo() returns None for formally invalid JSON strings.
236 resultset = self.makeStormResultSet()
237 range_factory = StormRangeFactory(resultset, self.logError)
238 self.assertIs(None, range_factory.parseMemo('foo'))
239 self.assertEqual(
240 ['memo is not a valid JSON string.'], self.error_messages)
241
242 def test_parseMemo__json_no_sequence(self):
243 # parseMemo() accepts only JSON representations of lists.
244 resultset = self.makeStormResultSet()
245 range_factory = StormRangeFactory(resultset, self.logError)
246 self.assertIs(None, range_factory.parseMemo(simplejson.dumps(1)))
247 self.assertEqual(
248 ['memo must be the JSON representation of a list.'],
249 self.error_messages)
250
251 def test_parseMemo__wrong_list_length(self):
252 # parseMemo() accepts only lists which have as many elements
253 # as the number of sort expressions used in the SQL query of
254 # the result set.
255 resultset = self.makeStormResultSet()
256 resultset.order_by(Person.name, Person.id)
257 range_factory = StormRangeFactory(resultset, self.logError)
258 self.assertIs(
259 None, range_factory.parseMemo(simplejson.dumps([1])))
260 expected_message = (
261 'Invalid number of elements in memo string. Expected: 2, got: 1')
262 self.assertEqual([expected_message], self.error_messages)
263
264 def test_parseMemo__memo_type_check(self):
265 # parseMemo() accepts only lists containing values that can
266 # be used in sort expression of the given result set.
267 resultset = self.makeStormResultSet()
268 resultset.order_by(Person.datecreated, Person.name, Person.id)
269 range_factory = StormRangeFactory(resultset, self.logError)
270 invalid_memo = [datetime(2011, 7, 25, 11, 30, 30, 45), 'foo', 'bar']
271 json_data = simplejson.dumps(invalid_memo, cls=DateTimeJSONEncoder)
272 self.assertIs(None, range_factory.parseMemo(json_data))
273 self.assertEqual(["Invalid parameter: 'bar'"], self.error_messages)
274
275 def test_parseMemo__valid_data(self):
276 # If a memo string contains valid data, parseMemo returns this data.
277 resultset = self.makeStormResultSet()
278 resultset.order_by(Person.datecreated, Person.name, Person.id)
279 range_factory = StormRangeFactory(resultset, self.logError)
280 valid_memo = [datetime(2011, 7, 25, 11, 30, 30, 45), 'foo', 1]
281 json_data = simplejson.dumps(valid_memo, cls=DateTimeJSONEncoder)
282 self.assertEqual(valid_memo, range_factory.parseMemo(json_data))
283 self.assertEqual(0, len(self.error_messages))
284
285 def test_parseMemo__short_iso_timestamp(self):
286 # An ISO timestamp without fractions of a second
287 # (YYYY-MM-DDThh:mm:ss) is a valid value for colums which
288 # store datetime values.
289 resultset = self.makeStormResultSet()
290 resultset.order_by(Person.datecreated)
291 range_factory = StormRangeFactory(resultset, self.logError)
292 valid_short_timestamp_json = '["2011-07-25T11:30:30"]'
293 self.assertEqual(
294 [datetime(2011, 7, 25, 11, 30, 30)],
295 range_factory.parseMemo(valid_short_timestamp_json))
296 self.assertEqual(0, len(self.error_messages))
297
298 def test_parseMemo__long_iso_timestamp(self):
299 # An ISO timestamp with fractions of a second
300 # (YYYY-MM-DDThh:mm:ss.ffffff) is a valid value for colums
301 # which store datetime values.
302 resultset = self.makeStormResultSet()
303 resultset.order_by(Person.datecreated)
304 range_factory = StormRangeFactory(resultset, self.logError)
305 valid_long_timestamp_json = '["2011-07-25T11:30:30.123456"]'
306 self.assertEqual(
307 [datetime(2011, 7, 25, 11, 30, 30, 123456)],
308 range_factory.parseMemo(valid_long_timestamp_json))
309 self.assertEqual(0, len(self.error_messages))
310
311 def test_parseMemo__invalid_iso_timestamp_value(self):
312 # An ISO timestamp with an invalid date is rejected as a memo
313 # string.
314 resultset = self.makeStormResultSet()
315 resultset.order_by(Person.datecreated)
316 range_factory = StormRangeFactory(resultset, self.logError)
317 invalid_timestamp_json = '["2011-05-35T11:30:30"]'
318 self.assertIs(
319 None, range_factory.parseMemo(invalid_timestamp_json))
320 self.assertEqual(
321 ["Invalid datetime value: '2011-05-35T11:30:30'"],
322 self.error_messages)
323
324 def test_parseMemo__nonsencial_iso_timestamp_value(self):
325 # A memo string is rejected when an ISO timespamp is expected
326 # but a nonsensical string is provided.
327 resultset = self.makeStormResultSet()
328 resultset.order_by(Person.datecreated)
329 range_factory = StormRangeFactory(resultset, self.logError)
330 nonsensical_timestamp_json = '["bar"]'
331 self.assertIs(
332 None, range_factory.parseMemo(nonsensical_timestamp_json))
333 self.assertEqual(
334 ["Invalid datetime value: 'bar'"],
335 self.error_messages)
336
337 def test_parseMemo__descending_sort_order(self):
338 # Validation of a memo string against a descending sort order works.
339 resultset = self.makeStormResultSet()
340 resultset.order_by(Desc(Person.id))
341 range_factory = StormRangeFactory(resultset, self.logError)
342 self.assertEqual(
343 [1], range_factory.parseMemo(simplejson.dumps([1])))
344
345 def test_reverseSortOrder(self):
346 # reverseSortOrder() wraps a plain PropertyColumn instance into
347 # Desc(), and it returns the plain PropertyCOlumn for a Desc()
348 # expression.
349 resultset = self.makeStormResultSet()
350 resultset.order_by(Person.id, Desc(Person.name))
351 range_factory = StormRangeFactory(resultset, self.logError)
352 reverse_person_id, person_name = range_factory.reverseSortOrder()
353 self.assertTrue(isinstance(reverse_person_id, Desc))
354 self.assertIs(Person.id, reverse_person_id.expr)
355 self.assertIs(Person.name, person_name)
356
357 def test_whereExpressionFromSortExpression__asc(self):
358 """For ascending sort order, whereExpressionFromSortExpression()
359 returns the WHERE clause expression > memo.
360 """
361 resultset = self.makeStormResultSet()
362 range_factory = StormRangeFactory(resultset, self.logError)
363 where_clause = range_factory.whereExpressionFromSortExpression(
364 expression=Person.id, memo=1)
365 self.assertTrue(isinstance(where_clause, Gt))
366 self.assertIs(where_clause.expr1, Person.id)
367 self.assertTrue(where_clause.expr2, IntVariable)
368
369 def test_whereExpressionFromSortExpression_desc(self):
370 """For descending sort order, whereExpressionFromSortExpression()
371 returns the WHERE clause expression < memo.
372 """
373 resultset = self.makeStormResultSet()
374 range_factory = StormRangeFactory(resultset, self.logError)
375 where_clause = range_factory.whereExpressionFromSortExpression(
376 expression=Desc(Person.id), memo=1)
377 self.assertTrue(isinstance(where_clause, Lt))
378 self.assertIs(where_clause.expr1, Person.id)
379 self.assertTrue(where_clause.expr2, IntVariable)
380
381 def test_getSlice__forward_without_memo(self):
382 resultset = self.makeStormResultSet()
383 resultset.order_by(Person.name, Person.id)
384 all_results = list(resultset)
385 range_factory = StormRangeFactory(resultset)
386 sliced_result = range_factory.getSlice(3)
387 self.assertEqual(all_results[:3], list(sliced_result))
388
389 def test_getSlice__forward_with_memo(self):
390 resultset = self.makeStormResultSet()
391 resultset.order_by(Person.name, Person.id)
392 all_results = list(resultset)
393 memo = simplejson.dumps([all_results[0].name, all_results[0].id])
394 range_factory = StormRangeFactory(resultset)
395 sliced_result = range_factory.getSlice(3, memo)
396 self.assertEqual(all_results[1:4], list(sliced_result))
397
398 def test_getSlice__backward_without_memo(self):
399 resultset = self.makeStormResultSet()
400 resultset.order_by(Person.name, Person.id)
401 all_results = list(resultset)
402 expected = all_results[-3:]
403 expected.reverse()
404 range_factory = StormRangeFactory(resultset)
405 sliced_result = range_factory.getSlice(3, forwards=False)
406 self.assertEqual(expected, list(sliced_result))
407
408 def test_getSlice_backward_with_memo(self):
409 resultset = self.makeStormResultSet()
410 resultset.order_by(Person.name, Person.id)
411 all_results = list(resultset)
412 expected = all_results[1:4]
413 expected.reverse()
414 memo = simplejson.dumps([all_results[4].name, all_results[4].id])
415 range_factory = StormRangeFactory(resultset)
416 sliced_result = range_factory.getSlice(3, memo, forwards=False)
417 self.assertEqual(expected, list(sliced_result))
418
419 def test_getSlice__decorated_resultset(self):
420 resultset = self.makeDecoratedStormResultSet()
421 resultset.order_by(LibraryFileAlias.id)
422 all_results = list(resultset)
423 memo = simplejson.dumps([resultset.getPlainResultSet()[0][1].id])
424 range_factory = StormRangeFactory(resultset)
425 sliced_result = range_factory.getSlice(3, memo)
426 self.assertEqual(all_results[1:4], list(sliced_result))
11427
12428
13def test_suite():429def test_suite():
14 suite = DocTestSuite(430 return TestLoader().loadTestsFromName(__name__)
15 'canonical.launchpad.webapp.batching',
16 optionflags=NORMALIZE_WHITESPACE | ELLIPSIS
17 )
18 return suite
19
20431
=== modified file 'lib/canonical/launchpad/zcml/decoratedresultset.zcml'
--- lib/canonical/launchpad/zcml/decoratedresultset.zcml 2010-08-19 19:52:31 +0000
+++ lib/canonical/launchpad/zcml/decoratedresultset.zcml 2011-07-28 11:31:31 +0000
@@ -10,7 +10,7 @@
1010
11 <class class="canonical.launchpad.components.decoratedresultset.DecoratedResultSet">11 <class class="canonical.launchpad.components.decoratedresultset.DecoratedResultSet">
12 <allow interface="storm.zope.interfaces.IResultSet" />12 <allow interface="storm.zope.interfaces.IResultSet" />
13 <allow attributes="__getslice__" />13 <allow attributes="__getslice__ getPlainResultSet" />
14 </class>14 </class>
1515
16</configure>16</configure>