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