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
1=== modified file 'lib/canonical/launchpad/components/decoratedresultset.py'
2--- lib/canonical/launchpad/components/decoratedresultset.py 2011-05-03 07:10:37 +0000
3+++ lib/canonical/launchpad/components/decoratedresultset.py 2011-07-28 11:31:31 +0000
4@@ -11,7 +11,10 @@
5 from lazr.delegates import delegates
6 from storm import Undef
7 from storm.zope.interfaces import IResultSet
8-from zope.security.proxy import removeSecurityProxy
9+from zope.security.proxy import (
10+ ProxyFactory,
11+ removeSecurityProxy,
12+ )
13
14
15 class DecoratedResultSet(object):
16@@ -170,3 +173,22 @@
17 return DecoratedResultSet(
18 new_result_set, self.result_decorator, self.pre_iter_hook,
19 self.slice_info)
20+
21+ def getPlainResultSet(self):
22+ """Return the plain Storm result set."""
23+ return self.result_set
24+
25+ def find(self, *args, **kwargs):
26+ """See `IResultSet`.
27+
28+ :return: The decorated version of the returned result set.
29+ """
30+ naked_result_set = removeSecurityProxy(self.result_set)
31+ if naked_result_set is not self.result_set:
32+ naked_new_result_set = naked_result_set.find(*args, **kwargs)
33+ new_result_set = ProxyFactory(naked_new_result_set)
34+ else:
35+ new_result_set = self.result_set.find(*args, **kwargs)
36+ return DecoratedResultSet(
37+ new_result_set, self.result_decorator, self.pre_iter_hook,
38+ self.slice_info)
39
40=== modified file 'lib/canonical/launchpad/components/tests/decoratedresultset.txt'
41--- lib/canonical/launchpad/components/tests/decoratedresultset.txt 2010-10-18 22:24:59 +0000
42+++ lib/canonical/launchpad/components/tests/decoratedresultset.txt 2011-07-28 11:31:31 +0000
43@@ -136,3 +136,19 @@
44 >>> isinstance(decorated_result_set[0:3], DecoratedResultSet)
45 True
46
47+== find() ==
48+
49+DecoratedResultSet.find() returns another DecoratedResultSet containing
50+a refined query.
51+
52+ >>> result_set = store.find(Distribution)
53+ >>> proxied_result_set = ProxyFactory(result_set)
54+ >>> decorated_result_set = DecoratedResultSet(
55+ ... proxied_result_set, result_decorator)
56+ >>> ubuntu_distros = removeSecurityProxy(decorated_result_set).find(
57+ ... "Distribution.name like 'ubuntu%'")
58+ >>> for dist in ubuntu_distros:
59+ ... dist
60+ u'Dist name is: ubuntu'
61+ u'Dist name is: ubuntutest'
62+
63
64=== modified file 'lib/canonical/launchpad/webapp/batching.py'
65--- lib/canonical/launchpad/webapp/batching.py 2011-07-13 06:17:19 +0000
66+++ lib/canonical/launchpad/webapp/batching.py 2011-07-28 11:31:31 +0000
67@@ -3,20 +3,38 @@
68
69 __metaclass__ = type
70
71+from datetime import datetime
72+
73 import lazr.batchnavigator
74+from lazr.batchnavigator.interfaces import IRangeFactory
75+import simplejson
76+from storm import Undef
77+from storm.expr import Desc
78+from storm.properties import PropertyColumn
79 from storm.zope.interfaces import IResultSet
80 from zope.component import adapts
81 from zope.interface import implements
82 from zope.interface.common.sequence import IFiniteSequence
83+from zope.security.proxy import (
84+ isinstance as zope_isinstance,
85+ ProxyFactory,
86+ removeSecurityProxy,
87+ )
88
89 from canonical.config import config
90-from canonical.launchpad.webapp.interfaces import ITableBatchNavigator
91+from canonical.launchpad.components.decoratedresultset import (
92+ DecoratedResultSet,
93+ )
94+from canonical.launchpad.webapp.interfaces import (
95+ ITableBatchNavigator,
96+ StormRangeFactoryError,
97+ )
98 from canonical.launchpad.webapp.publisher import LaunchpadView
99
100
101 class FiniteSequenceAdapter:
102
103- adapts(IResultSet) # and ISQLObjectResultSet
104+ adapts(IResultSet)
105 implements(IFiniteSequence)
106
107 def __init__(self, context):
108@@ -123,3 +141,216 @@
109 if columns_to_show:
110 for column_to_show in columns_to_show:
111 self.show_column[column_to_show] = True
112+
113+
114+class DateTimeJSONEncoder(simplejson.JSONEncoder):
115+ """A JSON encoder that understands datetime objects.
116+
117+ Datetime objects are formatted according to ISO 1601.
118+ """
119+ def default(self, obj):
120+ if isinstance(obj, datetime):
121+ return obj.isoformat()
122+ return simplejson.JSONEncoder.default(self, obj)
123+
124+
125+class StormRangeFactory:
126+ """A range factory for Storm result sets.
127+
128+ It creates the endpoint memo values from the expressions used in the
129+ ORDER BY clause.
130+
131+ Limitations:
132+
133+ - The order_by expressions must be Storm PropertyColumn instances,
134+ e.g. Bug.title. Simple strings (e.g., resultset.order_by('Bug.id')
135+ or general Storm SQL expression are not supported.
136+ - The objects representing rows of the PropertyColumn's table must
137+ be contained in the result. I.e.,
138+
139+ store.find(Bug.id, Bug.id < 10)
140+
141+ does not work, while
142+
143+ store.find(Bug, Bug.id < 10)
144+
145+ works.
146+ """
147+ implements(IRangeFactory)
148+
149+ def __init__(self, resultset, error_cb=None):
150+ """Create a new StormRangeFactory instance.
151+
152+ :param resultset: A Storm ResultSet instance or a DecoratedResultSet
153+ instance.
154+ :param error_cb: A function which takes one string as a parameter.
155+ It is called when the parameter endpoint_memo of getSlice()
156+ does not match the order settings of a resultset.
157+ """
158+ self.resultset = resultset
159+ if zope_isinstance(resultset, DecoratedResultSet):
160+ self.plain_resultset = resultset.getPlainResultSet()
161+ else:
162+ self.plain_resultset = resultset
163+ self.error_cb = error_cb
164+
165+ def getSortExpressions(self):
166+ """Return the order_by expressions of the result set."""
167+ return removeSecurityProxy(self.plain_resultset)._order_by
168+
169+ def getOrderValuesFor(self, row):
170+ """Return the values of the order_by expressions for the given row.
171+ """
172+ sort_values = []
173+ if not zope_isinstance(row, tuple):
174+ row = (row, )
175+ sort_expressions = self.getSortExpressions()
176+ if sort_expressions is Undef:
177+ raise StormRangeFactoryError(
178+ 'StormRangeFactory requires a sorted result set.')
179+ for expression in sort_expressions:
180+ if zope_isinstance(expression, Desc):
181+ expression = expression.expr
182+ if not zope_isinstance(expression, PropertyColumn):
183+ raise StormRangeFactoryError(
184+ 'StormRangeFactory supports only sorting by '
185+ 'PropertyColumn, not by %r.' % expression)
186+ class_instance_found = False
187+ for row_part in row:
188+ if zope_isinstance(row_part, expression.cls):
189+ sort_values.append(expression.__get__(row_part))
190+ class_instance_found = True
191+ break
192+ if not class_instance_found:
193+ raise StormRangeFactoryError(
194+ 'Instances of %r are not contained in the result set, '
195+ 'but are required to retrieve the value of %s.%s.'
196+ % (expression.cls, expression.cls.__name__,
197+ expression.name))
198+ return sort_values
199+
200+ def getEndpointMemos(self, batch):
201+ """See `IRangeFactory`."""
202+ lower = self.getOrderValuesFor(self.plain_resultset[0])
203+ upper = self.getOrderValuesFor(
204+ self.plain_resultset[batch.trueSize - 1])
205+ return (
206+ simplejson.dumps(lower, cls=DateTimeJSONEncoder),
207+ simplejson.dumps(upper, cls=DateTimeJSONEncoder),
208+ )
209+
210+ def reportError(self, message):
211+ if self.error_cb is not None:
212+ self.error_cb(message)
213+
214+ def parseMemo(self, memo):
215+ """Convert the given memo string into a sequence of Python objects.
216+
217+ memo should be a JSON string as returned by getEndpointMemos().
218+
219+ Note that memo originates from a URL query parameter and can thus
220+ not be trusted to always contain formally valid and consistent
221+ data.
222+
223+ Parsing errors or data not matching the sort parameters of the
224+ result set are simply ignored.
225+ """
226+ if memo == '':
227+ return None
228+ try:
229+ parsed_memo = simplejson.loads(memo)
230+ except simplejson.JSONDecodeError:
231+ self.reportError('memo is not a valid JSON string.')
232+ return None
233+ if not isinstance(parsed_memo, list):
234+ self.reportError(
235+ 'memo must be the JSON representation of a list.')
236+ return None
237+
238+ sort_expressions = self.getSortExpressions()
239+ if len(sort_expressions) != len(parsed_memo):
240+ self.reportError(
241+ 'Invalid number of elements in memo string. '
242+ 'Expected: %i, got: %i'
243+ % (len(sort_expressions), len(parsed_memo)))
244+ return None
245+
246+ converted_memo = []
247+ for expression, value in zip(sort_expressions, parsed_memo):
248+ if isinstance(expression, Desc):
249+ expression = expression.expr
250+ try:
251+ expression.variable_factory(value=value)
252+ except TypeError, error:
253+ # A TypeError is raised when the type of value cannot
254+ # be used for expression. All expected types are
255+ # properly created by simplejson.loads() above, except
256+ # time stamps which are represented as strings in
257+ # ISO format. If value is a string and if it can be
258+ # converted into a datetime object, we have a valid
259+ # value.
260+ if (str(error).startswith('Expected datetime') and
261+ isinstance(value, str)):
262+ try:
263+ value = datetime.strptime(
264+ value, '%Y-%m-%dT%H:%M:%S.%f')
265+ except ValueError:
266+ # One more attempt: If the fractions of a second
267+ # are zero, datetime.isoformat() omits the
268+ # entire part '.000000', so we need a different
269+ # format for strptime().
270+ try:
271+ value = datetime.strptime(
272+ value, '%Y-%m-%dT%H:%M:%S')
273+ except ValueError:
274+ self.reportError(
275+ 'Invalid datetime value: %r' % value)
276+ return None
277+ else:
278+ self.reportError(
279+ 'Invalid parameter: %r' % value)
280+ return None
281+ converted_memo.append(value)
282+ return converted_memo
283+
284+ def reverseSortOrder(self):
285+ """Return a list of reversed sort expressions."""
286+ def invert_sort_expression(expr):
287+ if isinstance(expression, Desc):
288+ return expression.expr
289+ else:
290+ return Desc(expression)
291+
292+ return [
293+ invert_sort_expression(expression)
294+ for expression in self.getSortExpressions()]
295+
296+ def whereExpressionFromSortExpression(self, expression, memo):
297+ """Create a Storm expression to be used in the WHERE clause of the
298+ slice query.
299+ """
300+ if isinstance(expression, Desc):
301+ expression = expression.expr
302+ return expression < memo
303+ else:
304+ return expression > memo
305+
306+ def getSlice(self, size, endpoint_memo='', forwards=True):
307+ """See `IRangeFactory`."""
308+ if not forwards:
309+ self.resultset.order_by(*self.reverseSortOrder())
310+ parsed_memo = self.parseMemo(endpoint_memo)
311+ if parsed_memo is None:
312+ return self.resultset.config(limit=size)
313+ else:
314+ sort_expressions = self.getSortExpressions()
315+ where = [
316+ self.whereExpressionFromSortExpression(expression, memo)
317+ for expression, memo in zip(sort_expressions, parsed_memo)]
318+ # From storm.zope.interfaces.IResultSet.__doc__:
319+ # - C{find()}, C{group_by()} and C{having()} are really
320+ # used to configure result sets, so are mostly intended
321+ # for use on the model side.
322+ naked_result = removeSecurityProxy(self.resultset).find(*where)
323+ result = ProxyFactory(naked_result)
324+ return result.config(limit=size)
325
326=== modified file 'lib/canonical/launchpad/webapp/interfaces.py'
327--- lib/canonical/launchpad/webapp/interfaces.py 2011-06-14 15:03:56 +0000
328+++ lib/canonical/launchpad/webapp/interfaces.py 2011-07-28 11:31:31 +0000
329@@ -878,3 +878,9 @@
330
331 def __init__(self, request):
332 self.request = request
333+
334+
335+class StormRangeFactoryError(AssertionError):
336+ """Raised when a Storm result set cannot be used for slicing by a
337+ StormRangeFactory.
338+ """
339
340=== modified file 'lib/canonical/launchpad/webapp/tests/test_batching.py'
341--- lib/canonical/launchpad/webapp/tests/test_batching.py 2010-08-20 20:31:18 +0000
342+++ lib/canonical/launchpad/webapp/tests/test_batching.py 2011-07-28 11:31:31 +0000
343@@ -1,19 +1,430 @@
344-# Copyright 2009 Canonical Ltd. This software is licensed under the
345+# Copyright 2011 Canonical Ltd. This software is licensed under the
346 # GNU Affero General Public License version 3 (see the file LICENSE).
347
348 __metaclass__ = type
349
350-from doctest import (
351- DocTestSuite,
352- ELLIPSIS,
353- NORMALIZE_WHITESPACE,
354- )
355+from datetime import datetime
356+import simplejson
357+from unittest import TestLoader
358+
359+from lazr.batchnavigator.interfaces import IRangeFactory
360+from storm.expr import (
361+ Desc,
362+ Gt,
363+ Lt,
364+ )
365+from storm.variables import IntVariable
366+from zope.security.proxy import isinstance as zope_isinstance
367+
368+from canonical.launchpad.components.decoratedresultset import (
369+ DecoratedResultSet,
370+ )
371+from canonical.launchpad.database.librarian import LibraryFileAlias
372+from canonical.launchpad.webapp.batching import (
373+ BatchNavigator,
374+ DateTimeJSONEncoder,
375+ StormRangeFactory,
376+ )
377+from canonical.launchpad.webapp.interfaces import StormRangeFactoryError
378+from canonical.launchpad.webapp.servers import LaunchpadTestRequest
379+from canonical.launchpad.webapp.testing import verifyObject
380+from canonical.testing.layers import LaunchpadFunctionalLayer
381+from lp.registry.model.person import Person
382+from lp.testing import (
383+ TestCaseWithFactory,
384+ person_logged_in,
385+ )
386+
387+
388+class TestStormRangeFactory(TestCaseWithFactory):
389+ """Tests for StormRangeFactory."""
390+
391+ layer = LaunchpadFunctionalLayer
392+
393+ def setUp(self):
394+ super(TestStormRangeFactory, self).setUp()
395+ self.error_messages = []
396+
397+ def makeStormResultSet(self):
398+ bug = self.factory.makeBug()
399+ for count in range(5):
400+ person = self.factory.makePerson()
401+ with person_logged_in(person):
402+ bug.markUserAffected(person, True)
403+ return bug.users_affected
404+
405+ def makeDecoratedStormResultSet(self):
406+ bug = self.factory.makeBug()
407+ with person_logged_in(bug.owner):
408+ for count in range(5):
409+ self.factory.makeBugAttachment(bug=bug, owner=bug.owner)
410+ result = bug.attachments
411+ self.assertTrue(zope_isinstance(result, DecoratedResultSet))
412+ return result
413+
414+ def test_StormRangeFactory_implements_IRangeFactory(self):
415+ resultset = self.makeStormResultSet()
416+ range_factory = StormRangeFactory(resultset)
417+ self.assertTrue(verifyObject(IRangeFactory, range_factory))
418+
419+ def test_getOrderValuesFor__one_sort_column(self):
420+ # StormRangeFactory.getOrderValuesFor() returns the values
421+ # of the fields used in order_by expresssions for a given
422+ # result row.
423+ resultset = self.makeStormResultSet()
424+ resultset.order_by(Person.id)
425+ range_factory = StormRangeFactory(resultset)
426+ order_values = range_factory.getOrderValuesFor(resultset[0])
427+ self.assertEqual([resultset[0].id], order_values)
428+
429+ def test_getOrderValuesFor__two_sort_columns(self):
430+ # Sorting by more than one column is supported.
431+ resultset = self.makeStormResultSet()
432+ resultset.order_by(Person.displayname, Person.name)
433+ range_factory = StormRangeFactory(resultset)
434+ order_values = range_factory.getOrderValuesFor(resultset[0])
435+ self.assertEqual(
436+ [resultset[0].displayname, resultset[0].name], order_values)
437+
438+ def test_getOrderValuesFor__string_as_sort_expression(self):
439+ # Sorting by a string expression is not supported.
440+ resultset = self.makeStormResultSet()
441+ resultset.order_by('Person.id')
442+ range_factory = StormRangeFactory(resultset)
443+ exception = self.assertRaises(
444+ StormRangeFactoryError, range_factory.getOrderValuesFor,
445+ resultset[0])
446+ self.assertEqual(
447+ "StormRangeFactory supports only sorting by PropertyColumn, "
448+ "not by 'Person.id'.",
449+ str(exception))
450+
451+ def test_getOrderValuesFor__generic_storm_expression_as_sort_expr(self):
452+ # Sorting by a generic Strom expression is not supported.
453+ resultset = self.makeStormResultSet()
454+ range_factory = StormRangeFactory(resultset)
455+ exception = self.assertRaises(
456+ StormRangeFactoryError, range_factory.getOrderValuesFor,
457+ resultset[0])
458+ self.assertTrue(
459+ str(exception).startswith(
460+ 'StormRangeFactory supports only sorting by PropertyColumn, '
461+ 'not by <storm.expr.SQL object at'))
462+
463+ def test_getOrderValuesFor__unordered_result_set(self):
464+ # If a result set is not ordered, it cannot be used with a
465+ # StormRangeFactory.
466+ resultset = self.makeStormResultSet()
467+ resultset.order_by()
468+ range_factory = StormRangeFactory(resultset)
469+ exception = self.assertRaises(
470+ StormRangeFactoryError, range_factory.getOrderValuesFor,
471+ resultset[0])
472+ self.assertEqual(
473+ "StormRangeFactory requires a sorted result set.",
474+ str(exception))
475+
476+ def test_getOrderValuesFor__decorated_result_set(self):
477+ # getOrderValuesFor() knows how to retrieve SQL sort values
478+ # from DecoratedResultSets.
479+ resultset = self.makeDecoratedStormResultSet()
480+ range_factory = StormRangeFactory(resultset)
481+ self.assertEqual(
482+ [resultset[0].id], range_factory.getOrderValuesFor(resultset[0]))
483+
484+ def test_getOrderValuesFor__value_from_second_element_of_result_row(self):
485+ # getOrderValuesFor() can retrieve values from attributes
486+ # of any Storm table class instance which appear in a result row.
487+ resultset = self.makeDecoratedStormResultSet()
488+ resultset = resultset.order_by(LibraryFileAlias.id)
489+ plain_resultset = resultset.getPlainResultSet()
490+ range_factory = StormRangeFactory(resultset)
491+ self.assertEqual(
492+ [plain_resultset[0][1].id],
493+ range_factory.getOrderValuesFor(plain_resultset[0]))
494+
495+ def test_getOrderValuesFor__descending_sort_order(self):
496+ # getOrderValuesFor() can retrieve values from reverse sorted
497+ # columns.
498+ resultset = self.makeStormResultSet()
499+ resultset = resultset.order_by(Desc(Person.id))
500+ range_factory = StormRangeFactory(resultset)
501+ self.assertEqual(
502+ [resultset[0].id], range_factory.getOrderValuesFor(resultset[0]))
503+
504+ def test_getOrderValuesFor__table_not_included_in_results(self):
505+ # Attempts to use a sort by a column which does not appear in the
506+ # data returned by the query raise a StormRangeFactoryError.
507+ resultset = self.makeStormResultSet()
508+ resultset.order_by(LibraryFileAlias.id)
509+ range_factory = StormRangeFactory(resultset)
510+ exception = self.assertRaises(
511+ StormRangeFactoryError, range_factory.getOrderValuesFor,
512+ resultset[0])
513+ self.assertEqual(
514+ "Instances of <class "
515+ "'canonical.launchpad.database.librarian.LibraryFileAlias'> are "
516+ "not contained in the result set, but are required to retrieve "
517+ "the value of LibraryFileAlias.id.",
518+ str(exception))
519+
520+ def test_DatetimeJSONEncoder(self):
521+ # DateTimeJSONEncoder represents Pytjon datetime objects as strings
522+ # where the value is represented in the ISO time format.
523+ self.assertEqual(
524+ '"2011-07-25T00:00:00"',
525+ simplejson.dumps(datetime(2011, 7, 25), cls=DateTimeJSONEncoder))
526+
527+ # DateTimeJSONEncoder works for the regular Python types that can
528+ # represented as JSON strings.
529+ encoded = simplejson.dumps(
530+ ('foo', 1, 2.0, [3, 4], {5: 'bar'}, datetime(2011, 7, 24)),
531+ cls=DateTimeJSONEncoder)
532+ self.assertEqual(
533+ '["foo", 1, 2.0, [3, 4], {"5": "bar"}, "2011-07-24T00:00:00"]',
534+ encoded
535+ )
536+
537+ def test_getEndpointMemos(self):
538+ # getEndpointMemos() returns JSON representations of the
539+ # sort fields of the first and last element of a batch.
540+ resultset = self.makeStormResultSet()
541+ resultset.order_by(Person.name)
542+ request = LaunchpadTestRequest()
543+ batchnav = BatchNavigator(
544+ resultset, request, size=3, range_factory=StormRangeFactory)
545+ range_factory = StormRangeFactory(resultset)
546+ first, last = range_factory.getEndpointMemos(batchnav.batch)
547+ expected_first = simplejson.dumps(
548+ [resultset[0].name], cls=DateTimeJSONEncoder)
549+ expected_last = simplejson.dumps(
550+ [resultset[2].name], cls=DateTimeJSONEncoder)
551+ self.assertEqual(expected_first, first)
552+ self.assertEqual(expected_last, last)
553+
554+ def test_getEndpointMemos__decorated_result_set(self):
555+ # getEndpointMemos() works for DecoratedResultSet
556+ # instances too.
557+ resultset = self.makeDecoratedStormResultSet()
558+ resultset.order_by(LibraryFileAlias.id)
559+ request = LaunchpadTestRequest()
560+ batchnav = BatchNavigator(
561+ resultset, request, size=3, range_factory=StormRangeFactory)
562+ range_factory = StormRangeFactory(resultset)
563+ first, last = range_factory.getEndpointMemos(batchnav.batch)
564+ expected_first = simplejson.dumps(
565+ [resultset.getPlainResultSet()[0][1].id], cls=DateTimeJSONEncoder)
566+ expected_last = simplejson.dumps(
567+ [resultset.getPlainResultSet()[2][1].id],
568+ cls=DateTimeJSONEncoder)
569+ self.assertEqual(expected_first, first)
570+ self.assertEqual(expected_last, last)
571+
572+ def logError(self, message):
573+ # An error callback for StormResultSet.
574+ self.error_messages.append(message)
575+
576+ def test_parseMemo__empty_value(self):
577+ # parseMemo() returns None for an empty memo value.
578+ resultset = self.makeStormResultSet()
579+ range_factory = StormRangeFactory(resultset, self.logError)
580+ self.assertIs(None, range_factory.parseMemo(''))
581+ self.assertEqual(0, len(self.error_messages))
582+
583+ def test_parseMemo__json_error(self):
584+ # parseMemo() returns None for formally invalid JSON strings.
585+ resultset = self.makeStormResultSet()
586+ range_factory = StormRangeFactory(resultset, self.logError)
587+ self.assertIs(None, range_factory.parseMemo('foo'))
588+ self.assertEqual(
589+ ['memo is not a valid JSON string.'], self.error_messages)
590+
591+ def test_parseMemo__json_no_sequence(self):
592+ # parseMemo() accepts only JSON representations of lists.
593+ resultset = self.makeStormResultSet()
594+ range_factory = StormRangeFactory(resultset, self.logError)
595+ self.assertIs(None, range_factory.parseMemo(simplejson.dumps(1)))
596+ self.assertEqual(
597+ ['memo must be the JSON representation of a list.'],
598+ self.error_messages)
599+
600+ def test_parseMemo__wrong_list_length(self):
601+ # parseMemo() accepts only lists which have as many elements
602+ # as the number of sort expressions used in the SQL query of
603+ # the result set.
604+ resultset = self.makeStormResultSet()
605+ resultset.order_by(Person.name, Person.id)
606+ range_factory = StormRangeFactory(resultset, self.logError)
607+ self.assertIs(
608+ None, range_factory.parseMemo(simplejson.dumps([1])))
609+ expected_message = (
610+ 'Invalid number of elements in memo string. Expected: 2, got: 1')
611+ self.assertEqual([expected_message], self.error_messages)
612+
613+ def test_parseMemo__memo_type_check(self):
614+ # parseMemo() accepts only lists containing values that can
615+ # be used in sort expression of the given result set.
616+ resultset = self.makeStormResultSet()
617+ resultset.order_by(Person.datecreated, Person.name, Person.id)
618+ range_factory = StormRangeFactory(resultset, self.logError)
619+ invalid_memo = [datetime(2011, 7, 25, 11, 30, 30, 45), 'foo', 'bar']
620+ json_data = simplejson.dumps(invalid_memo, cls=DateTimeJSONEncoder)
621+ self.assertIs(None, range_factory.parseMemo(json_data))
622+ self.assertEqual(["Invalid parameter: 'bar'"], self.error_messages)
623+
624+ def test_parseMemo__valid_data(self):
625+ # If a memo string contains valid data, parseMemo returns this data.
626+ resultset = self.makeStormResultSet()
627+ resultset.order_by(Person.datecreated, Person.name, Person.id)
628+ range_factory = StormRangeFactory(resultset, self.logError)
629+ valid_memo = [datetime(2011, 7, 25, 11, 30, 30, 45), 'foo', 1]
630+ json_data = simplejson.dumps(valid_memo, cls=DateTimeJSONEncoder)
631+ self.assertEqual(valid_memo, range_factory.parseMemo(json_data))
632+ self.assertEqual(0, len(self.error_messages))
633+
634+ def test_parseMemo__short_iso_timestamp(self):
635+ # An ISO timestamp without fractions of a second
636+ # (YYYY-MM-DDThh:mm:ss) is a valid value for colums which
637+ # store datetime values.
638+ resultset = self.makeStormResultSet()
639+ resultset.order_by(Person.datecreated)
640+ range_factory = StormRangeFactory(resultset, self.logError)
641+ valid_short_timestamp_json = '["2011-07-25T11:30:30"]'
642+ self.assertEqual(
643+ [datetime(2011, 7, 25, 11, 30, 30)],
644+ range_factory.parseMemo(valid_short_timestamp_json))
645+ self.assertEqual(0, len(self.error_messages))
646+
647+ def test_parseMemo__long_iso_timestamp(self):
648+ # An ISO timestamp with fractions of a second
649+ # (YYYY-MM-DDThh:mm:ss.ffffff) is a valid value for colums
650+ # which store datetime values.
651+ resultset = self.makeStormResultSet()
652+ resultset.order_by(Person.datecreated)
653+ range_factory = StormRangeFactory(resultset, self.logError)
654+ valid_long_timestamp_json = '["2011-07-25T11:30:30.123456"]'
655+ self.assertEqual(
656+ [datetime(2011, 7, 25, 11, 30, 30, 123456)],
657+ range_factory.parseMemo(valid_long_timestamp_json))
658+ self.assertEqual(0, len(self.error_messages))
659+
660+ def test_parseMemo__invalid_iso_timestamp_value(self):
661+ # An ISO timestamp with an invalid date is rejected as a memo
662+ # string.
663+ resultset = self.makeStormResultSet()
664+ resultset.order_by(Person.datecreated)
665+ range_factory = StormRangeFactory(resultset, self.logError)
666+ invalid_timestamp_json = '["2011-05-35T11:30:30"]'
667+ self.assertIs(
668+ None, range_factory.parseMemo(invalid_timestamp_json))
669+ self.assertEqual(
670+ ["Invalid datetime value: '2011-05-35T11:30:30'"],
671+ self.error_messages)
672+
673+ def test_parseMemo__nonsencial_iso_timestamp_value(self):
674+ # A memo string is rejected when an ISO timespamp is expected
675+ # but a nonsensical string is provided.
676+ resultset = self.makeStormResultSet()
677+ resultset.order_by(Person.datecreated)
678+ range_factory = StormRangeFactory(resultset, self.logError)
679+ nonsensical_timestamp_json = '["bar"]'
680+ self.assertIs(
681+ None, range_factory.parseMemo(nonsensical_timestamp_json))
682+ self.assertEqual(
683+ ["Invalid datetime value: 'bar'"],
684+ self.error_messages)
685+
686+ def test_parseMemo__descending_sort_order(self):
687+ # Validation of a memo string against a descending sort order works.
688+ resultset = self.makeStormResultSet()
689+ resultset.order_by(Desc(Person.id))
690+ range_factory = StormRangeFactory(resultset, self.logError)
691+ self.assertEqual(
692+ [1], range_factory.parseMemo(simplejson.dumps([1])))
693+
694+ def test_reverseSortOrder(self):
695+ # reverseSortOrder() wraps a plain PropertyColumn instance into
696+ # Desc(), and it returns the plain PropertyCOlumn for a Desc()
697+ # expression.
698+ resultset = self.makeStormResultSet()
699+ resultset.order_by(Person.id, Desc(Person.name))
700+ range_factory = StormRangeFactory(resultset, self.logError)
701+ reverse_person_id, person_name = range_factory.reverseSortOrder()
702+ self.assertTrue(isinstance(reverse_person_id, Desc))
703+ self.assertIs(Person.id, reverse_person_id.expr)
704+ self.assertIs(Person.name, person_name)
705+
706+ def test_whereExpressionFromSortExpression__asc(self):
707+ """For ascending sort order, whereExpressionFromSortExpression()
708+ returns the WHERE clause expression > memo.
709+ """
710+ resultset = self.makeStormResultSet()
711+ range_factory = StormRangeFactory(resultset, self.logError)
712+ where_clause = range_factory.whereExpressionFromSortExpression(
713+ expression=Person.id, memo=1)
714+ self.assertTrue(isinstance(where_clause, Gt))
715+ self.assertIs(where_clause.expr1, Person.id)
716+ self.assertTrue(where_clause.expr2, IntVariable)
717+
718+ def test_whereExpressionFromSortExpression_desc(self):
719+ """For descending sort order, whereExpressionFromSortExpression()
720+ returns the WHERE clause expression < memo.
721+ """
722+ resultset = self.makeStormResultSet()
723+ range_factory = StormRangeFactory(resultset, self.logError)
724+ where_clause = range_factory.whereExpressionFromSortExpression(
725+ expression=Desc(Person.id), memo=1)
726+ self.assertTrue(isinstance(where_clause, Lt))
727+ self.assertIs(where_clause.expr1, Person.id)
728+ self.assertTrue(where_clause.expr2, IntVariable)
729+
730+ def test_getSlice__forward_without_memo(self):
731+ resultset = self.makeStormResultSet()
732+ resultset.order_by(Person.name, Person.id)
733+ all_results = list(resultset)
734+ range_factory = StormRangeFactory(resultset)
735+ sliced_result = range_factory.getSlice(3)
736+ self.assertEqual(all_results[:3], list(sliced_result))
737+
738+ def test_getSlice__forward_with_memo(self):
739+ resultset = self.makeStormResultSet()
740+ resultset.order_by(Person.name, Person.id)
741+ all_results = list(resultset)
742+ memo = simplejson.dumps([all_results[0].name, all_results[0].id])
743+ range_factory = StormRangeFactory(resultset)
744+ sliced_result = range_factory.getSlice(3, memo)
745+ self.assertEqual(all_results[1:4], list(sliced_result))
746+
747+ def test_getSlice__backward_without_memo(self):
748+ resultset = self.makeStormResultSet()
749+ resultset.order_by(Person.name, Person.id)
750+ all_results = list(resultset)
751+ expected = all_results[-3:]
752+ expected.reverse()
753+ range_factory = StormRangeFactory(resultset)
754+ sliced_result = range_factory.getSlice(3, forwards=False)
755+ self.assertEqual(expected, list(sliced_result))
756+
757+ def test_getSlice_backward_with_memo(self):
758+ resultset = self.makeStormResultSet()
759+ resultset.order_by(Person.name, Person.id)
760+ all_results = list(resultset)
761+ expected = all_results[1:4]
762+ expected.reverse()
763+ memo = simplejson.dumps([all_results[4].name, all_results[4].id])
764+ range_factory = StormRangeFactory(resultset)
765+ sliced_result = range_factory.getSlice(3, memo, forwards=False)
766+ self.assertEqual(expected, list(sliced_result))
767+
768+ def test_getSlice__decorated_resultset(self):
769+ resultset = self.makeDecoratedStormResultSet()
770+ resultset.order_by(LibraryFileAlias.id)
771+ all_results = list(resultset)
772+ memo = simplejson.dumps([resultset.getPlainResultSet()[0][1].id])
773+ range_factory = StormRangeFactory(resultset)
774+ sliced_result = range_factory.getSlice(3, memo)
775+ self.assertEqual(all_results[1:4], list(sliced_result))
776
777
778 def test_suite():
779- suite = DocTestSuite(
780- 'canonical.launchpad.webapp.batching',
781- optionflags=NORMALIZE_WHITESPACE | ELLIPSIS
782- )
783- return suite
784-
785+ return TestLoader().loadTestsFromName(__name__)
786
787=== modified file 'lib/canonical/launchpad/zcml/decoratedresultset.zcml'
788--- lib/canonical/launchpad/zcml/decoratedresultset.zcml 2010-08-19 19:52:31 +0000
789+++ lib/canonical/launchpad/zcml/decoratedresultset.zcml 2011-07-28 11:31:31 +0000
790@@ -10,7 +10,7 @@
791
792 <class class="canonical.launchpad.components.decoratedresultset.DecoratedResultSet">
793 <allow interface="storm.zope.interfaces.IResultSet" />
794- <allow attributes="__getslice__" />
795+ <allow attributes="__getslice__ getPlainResultSet" />
796 </class>
797
798 </configure>