Merge lp:~adeuring/lazr.batchnavigator/lazr.batchnavigator-no-start-param-for-first-last-url into lp:lazr.batchnavigator

Proposed by Abel Deuring
Status: Merged
Merged at revision: 50
Proposed branch: lp:~adeuring/lazr.batchnavigator/lazr.batchnavigator-no-start-param-for-first-last-url
Merge into: lp:lazr.batchnavigator
Diff against target: 241 lines (+118/-18)
5 files modified
src/lazr/batchnavigator/_batchnavigator.py (+2/-2)
src/lazr/batchnavigator/tests/test_batchnavigator.py (+17/-0)
src/lazr/batchnavigator/tests/test_z3batching.py (+60/-8)
src/lazr/batchnavigator/z3batching/batch.py (+33/-8)
src/lazr/batchnavigator/z3batching/interfaces.py (+6/-0)
To merge this branch: bzr merge lp:~adeuring/lazr.batchnavigator/lazr.batchnavigator-no-start-param-for-first-last-url
Reviewer Review Type Date Requested Status
Graham Binns (community) code Approve
Review via email: mp+74249@code.launchpad.net

Description of the change

BatchNavigator.firstBatchURL() and BatchNavigator.lastBatchURL()
use the parameter start to determine if a "real" URL is returned
or if an empty URL is returned.

This works mostly fine when BatchNavigator can use a
ListRangeFactory, but it can lead to some confusion when a
StormRangeFactory is used. And even with a ListRangeFactory it
is possible to manually change the URL so that firstBatchURL()
and lastBatchURL() return an empty string: Just visit any
batched Launchpad view, click on the "next" link and then change
the query parameter "start=N" to "start=0". The page displayed
for this URL still shows the second batch, because BatchNavigator
and ListRangeFactory use the query parameter "memo" instead of
"start" to determine what to display. But the the "first batch"
on the page is just grey text instead of a "real" link.

For StormRangeFactory, this becomes a more serious problem, for
at least two reasons:

1:
StormRangeFactory does need to know the exact length of the
result set, which allows us to avoid expensive calls of
ResultSet.count(); we can use estimates of the row count
instead. This makes expressions like

   batch_start + batch_size > length_of_result_set

unreliable.

2:
* A user visits the first page of a batched list and clicks
   on the "next" link
* For some reason, the result set changes so that another
  element appears at the start of the result set.
* When the user now clicks the "previous" link, a view
  using StormRangeFactory will return that part of the result
  set coming directly before the current batch. This means
  that there another previous batch, starting with the newly
  created element. But the parameter "start" will still be
  zero, thus disabing the "first batch" link. (The text
  "previous batch" will still be a proper link.)
  (ListRangeFactory will return the first batch, now starting
  with the new element)

The code changes are simple:

_Batch.prevBatch() and _Batch.nextBatch() already had all
the logic needed to reliably determine if the current batch
is the first/last batch. I moced this logic into two new
properties, _Batch.has_previous_batch and _Batch.has_next_batch.

While prevBatch() and nextBatch() return None if the current
batch is the first/last batch, firstBatch()/lastBatch() always
return a _Batch instance, and firstBatchURL()/lastbatchURL()
determine if a real link or an empty string should be returned.
This decision was based to the value of batch.start, but is now
based on the the new properties.

I also noticed that an existing test did not test what it was
supposed to test -- this required a range factory which works
similar to StormRangeFactory, as described above. This test
is also about the behaviour of _Batch when the the result set
changes, as described above.)

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) wrote :

Hi Abel,

Nice branch, just a few nitpicky things that need to be taken care of
before landing. r=me.

[1]

30 + def test_lastBatchURL_not_empty_for_bogus_start_value(self):
31 + # lastBatchURL() is correct even when the start parameter
32 + # is bogus.
33 + batchnav = sample_batchnav(start=9, batch=3, memo="6")
34 + self.assertThat(batchnav.lastBatchURL(),
35 + EqualsQuery(start=9, batch=3, direction='backwards'))
36 +

Why is the start parameter bogus here? Is that explained elsewhere? If
not, I think this needs a comment to explain why start=9 is bogus for
the batchnav you've created.

[2]

41 + def test_firstBatchURL_does_not_depend_on_start_parameter(self):
42 + # nextBatchURL() is correct even when start has the (incorrect)
43 + # value 0.
44 + batchnav = sample_batchnav(start=0, batch=3, memo='3')
45 + self.assertThat(batchnav.firstBatchURL(), EqualsQuery(batch=3))

Same again, really. If these tests were failing and I had to look at
them I wouldn't understand why the values you're passing are incorrect.
Explaining it here saves time down the road.

[3]

106 + def test_has_next_batch__forwards_with_memo_not_at_end(self):

111 + def test_has_next_batch__forwards_with_memo_at_end(self):

116 + def test_has_next_batch__forwards_without_memo_not_at_end(self):

120 + def test_has_next_batch__forwards_without_memo_at_end(self):

124 + def test_has_next_batch__backwards_with_memo_not_at_end(self):

129 + def test_has_next_batch__backforwards_with_memo_at_end(self):

I know it's obvious to you what these mean, but the act of truncating
the method names to fit within our character limits has stripped them of
context for someone who has not to start with (i.e. me). I'd appreciate
even a short comment at the start of each test explaining the expected
behaviour.

[4]

166 + return (
167 + (self.range_forwards and len(self.sliced_list) > self.size) or
168 + (not self.range_forwards and self.range_memo != ''))

That which is concise is often hard to parse. How about:

    if self.range_forwards:
        return len(self.sliced_list) > self.size
    else:
        return self.range_memo != ''

to make it clearer to chumps like me?

review: Approve (code)
52. By Abel Deuring

implemented reviewer's comments

53. By Abel Deuring

Deuring: never commit without first running bin/test: now fix the two syntax errors.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/lazr/batchnavigator/_batchnavigator.py'
2--- src/lazr/batchnavigator/_batchnavigator.py 2011-08-19 18:53:54 +0000
3+++ src/lazr/batchnavigator/_batchnavigator.py 2011-09-07 09:29:23 +0000
4@@ -274,7 +274,7 @@
5
6 def firstBatchURL(self):
7 batch = self.batch.firstBatch()
8- if self.start == 0:
9+ if not self.batch.has_previous_batch:
10 # We are already on the first batch.
11 batch = None
12 return self.generateBatchURL(batch)
13@@ -287,7 +287,7 @@
14
15 def lastBatchURL(self):
16 batch = self.batch.lastBatch()
17- if self.start == batch.start:
18+ if not self.batch.has_next_batch:
19 # We are already on the last batch.
20 batch = None
21 return self.generateBatchURL(batch, backwards=True)
22
23=== modified file 'src/lazr/batchnavigator/tests/test_batchnavigator.py'
24--- src/lazr/batchnavigator/tests/test_batchnavigator.py 2011-08-19 18:53:54 +0000
25+++ src/lazr/batchnavigator/tests/test_batchnavigator.py 2011-09-07 09:29:23 +0000
26@@ -149,10 +149,27 @@
27 self.assertThat(batchnav.lastBatchURL(),
28 EqualsQuery(start=9, batch=3, direction='backwards'))
29
30+ def test_lastBatchURL_not_empty_for_bogus_start_value(self):
31+ # lastBatchURL() is correct even when the start parameter
32+ # is bogus: memo says that the batch begins at offset
33+ # 6, while start points to the last element of the
34+ # of the batch.
35+ batchnav = sample_batchnav(start=9, batch=3, memo="6")
36+ self.assertThat(batchnav.lastBatchURL(),
37+ EqualsQuery(start=9, batch=3, direction='backwards'))
38+
39 def test_firstBatchURL_is_trivial(self):
40 batchnav = sample_batchnav(start=3, batch=3, memo='3')
41 self.assertThat(batchnav.firstBatchURL(), EqualsQuery(batch=3))
42
43+ def test_firstBatchURL_does_not_depend_on_start_parameter(self):
44+ # nextBatchURL() is correct even when start has the (incorrect)
45+ # value 0. Start = 0 implies that we are at the first batch.
46+ # But this value may be wrong (see _Batch.__init__()), and
47+ # firstBatchURL() does not rely on it.
48+ batchnav = sample_batchnav(start=0, batch=3, memo='3')
49+ self.assertThat(batchnav.firstBatchURL(), EqualsQuery(batch=3))
50+
51 def test_nextBatchURL_has_memo(self):
52 batchnav = sample_batchnav(start=3, batch=3, memo='3')
53 self.assertThat(batchnav.nextBatchURL(),
54
55=== modified file 'src/lazr/batchnavigator/tests/test_z3batching.py'
56--- src/lazr/batchnavigator/tests/test_z3batching.py 2011-08-24 15:40:20 +0000
57+++ src/lazr/batchnavigator/tests/test_z3batching.py 2011-09-07 09:29:23 +0000
58@@ -581,16 +581,17 @@
59 # The start parameter of a backwards batch may reach zero
60 # even when the real begin of a result set is not yet reached.
61 data = [1, 2, 3, 4, 5, 6]
62+ range_factory = RangeFactoryWithValueBasedEndpointMemos(data)
63 # We create the first backwards.
64 batch = _Batch(
65- data, range_factory=ListRangeFactory(data),
66- size=3, range_memo='', start=3, range_forwards=False)
67+ data, range_factory=range_factory, size=3, range_memo='',
68+ start=3, range_forwards=False)
69 self.assertEqual([4, 5, 6], batch.sliced_list)
70 self.assertEqual(3, batch.start)
71 # The previous batch is now the first batch.
72 batch = batch.prevBatch()
73 self.assertEqual(0, batch.start)
74- self.assertEqual("3", batch.range_memo)
75+ self.assertEqual(4, batch.range_memo)
76 self.assertEqual([1, 2, 3, 4], batch.sliced_list)
77 self.assertTrue(batch.prevBatch() is None)
78 # If we now insert another value at the start of the result set,
79@@ -598,14 +599,20 @@
80 # we get the same data slice, but also a prevBtach()
81 # that is not None.
82 data = [0, 1, 2, 3, 4, 5, 6]
83+ range_factory = RangeFactoryWithValueBasedEndpointMemos(data)
84 new_batch = _Batch(
85- data, range_factory=ListRangeFactory(data),
86- size=3, range_memo=batch.range_memo, start=batch.start,
87+ data, range_factory=range_factory, size=3,
88+ range_memo=batch.range_memo, start=batch.start,
89 range_forwards=False)
90- # the contents of the batch is still correct.
91- self.assertEqual([1, 2, 3, 4], batch.sliced_list)
92+ # the contents of the batch is still correct. (Note that the
93+ # value of the last does not matter, see _Batch.sliced_list().)
94+ self.assertEqual([1, 2, 3, 0], new_batch.sliced_list)
95 # And we get a previous batch.
96 self.assertEqual([0, 1, 2, 3], new_batch.prevBatch().sliced_list)
97+ # Note that both new_batch and its previous batch claim to start
98+ # at 0.
99+ self.assertEqual(0, new_batch.start)
100+ self.assertEqual(0, new_batch.prevBatch().start)
101
102 def test_last_backwards_batch_with_value_based_range_factory(self):
103 # Another slice is added in _Batch.sliced_list() when the
104@@ -647,4 +654,49 @@
105 # startNumber, endNumber() are implemented
106 self.assertEqual(1, partial.startNumber())
107 self.assertEqual(4, partial.endNumber())
108-
109+
110+ def test_has_next_batch__forwards_with_memo_not_at_end(self):
111+ # If a batch is not the last batch for a result set,
112+ # has_next_batch is True. When the parameter range_memo
113+ # is given, the value of start does not matter.
114+ batch = self.getBatch(
115+ start=7, size=3, range_forwards=True, range_memo="6")
116+ self.assertTrue(batch.has_next_batch)
117+
118+ def test_has_next_batch__forwards_with_memo_at_end(self):
119+ # If a batch is the last batch for a result set,
120+ # has_next_batch is False. When the parameter range_memo
121+ # is given, the value of start does not matter.
122+ batch = self.getBatch(
123+ start=6, size=3, range_forwards=True, range_memo="7")
124+ self.assertFalse(batch.has_next_batch)
125+
126+ def test_has_next_batch__forwards_without_memo_not_at_end(self):
127+ # If a batch is the last batch for a result set,
128+ # has_next_batch is False. When the parameter range_memo
129+ # is not given, the value of start is used.
130+ batch = self.getBatch(start=6, size=3)
131+ self.assertTrue(batch.has_next_batch)
132+
133+ def test_has_next_batch__forwards_without_memo_at_end(self):
134+ # If a batch is the last batch for a result set,
135+ # has_next_batch is False. When the parameter range_memo
136+ # is not given, the value of start is used.
137+ batch = self.getBatch(start=7, size=3)
138+ self.assertFalse(batch.has_next_batch)
139+
140+ def test_has_previous_batch__backwards_with_memo_not_at_end(self):
141+ # If a batch is not the first batch for a result set,
142+ # has_previous_batch is True. The value of start does not
143+ # matter.
144+ batch = self.getBatch(
145+ start=0, size=3, range_forwards=False, range_memo="4")
146+ self.assertTrue(batch.has_previous_batch)
147+
148+ def test_has_previous_batch__backwards_with_memo_at_end(self):
149+ # If a batch is not the first batch for a result set,
150+ # has_previous_batch is False.The value of start does not
151+ # matter.
152+ batch = self.getBatch(
153+ start=1, size=3, range_forwards=False, range_memo="3")
154+ self.assertFalse(batch.has_previous_batch)
155
156=== modified file 'src/lazr/batchnavigator/z3batching/batch.py'
157--- src/lazr/batchnavigator/z3batching/batch.py 2011-08-24 15:40:20 +0000
158+++ src/lazr/batchnavigator/z3batching/batch.py 2011-09-07 09:29:23 +0000
159@@ -109,6 +109,10 @@
160 """See `IBatch`."""
161 return len(self.sliced_list) + 1
162
163+ # The values do not matter at all. Just make verifyObject() happy.
164+ has_previous_batch = None
165+ has_next_batch = None
166+
167
168 class _Batch(object):
169 implements(IBatch)
170@@ -287,17 +291,22 @@
171 def __contains__(self, item):
172 return item in self.__iter__()
173
174- def nextBatch(self):
175+ @property
176+ def has_next_batch(self):
177+ """See `IBatch`."""
178 # self.sliced_list tries to return one more object than the
179 # batch size. If it returns the batch size, or fewer, then
180 # this batch encompasses the end of the list, for forward
181 # batching.
182- if self.range_forwards and len(self.sliced_list) <= self.size:
183- return None
184-
185 # In the case of backward batching, an empty memo value
186 # indicates that this is the last batch.
187- if not self.range_forwards and self.range_memo == '':
188+ if self.range_forwards:
189+ return len(self.sliced_list) > self.size
190+ else:
191+ return self.range_memo != ''
192+
193+ def nextBatch(self):
194+ if not self.has_next_batch:
195 return None
196
197 start = self.start + self.size
198@@ -306,10 +315,26 @@
199 range_memo=memos[1], range_forwards=True,
200 _listlength=self._listlength)
201
202+ @property
203+ def has_previous_batch(self):
204+ """See `IBatch`."""
205+ if self.range_memo is None:
206+ # If no range memo is specified, sliced_list() falls back
207+ # to slicing by index. This happens for old URLs, for example.
208+ return self.start > 0
209+ if self.range_forwards:
210+ # For forward batching, we are at the first batch if the memo
211+ # value is empty.
212+ return self.range_memo != ''
213+ else:
214+ # For backwards batching, we can rely on the flag set in
215+ # sliced_list. sliced_list is a @Lazy property, so make
216+ # sure that it has been evaluated.
217+ self.sliced_list
218+ return not self.is_first_batch
219+
220 def prevBatch(self):
221- if self.range_forwards and self.range_memo == '':
222- return None
223- if not self.range_forwards and self.is_first_batch:
224+ if not self.has_previous_batch:
225 return None
226
227 # The only case in which we should /not/ offer a previous batch
228
229=== modified file 'src/lazr/batchnavigator/z3batching/interfaces.py'
230--- src/lazr/batchnavigator/z3batching/interfaces.py 2011-08-24 15:40:20 +0000
231+++ src/lazr/batchnavigator/z3batching/interfaces.py 2011-09-07 09:29:23 +0000
232@@ -69,3 +69,9 @@
233 "A sliced list as returned by IRangeFactory.sliced_list.")
234
235 trueSize = Attribute("The actual size of this batch.")
236+
237+ has_previous_batch = Attribute(
238+ "True, if this batch has a predecessor, else False.")
239+
240+ has_next_batch = Attribute(
241+ "True, if this batch has a successor, else False.")

Subscribers

People subscribed via source and target branches