Skip to content

Commit 97278d8

Browse files
committed
Merge pull request #204 from tseaver/feature-datastore_query_cursor
Add cursor handling to datastore.query.Query.
2 parents f898953 + f7c8066 commit 97278d8

File tree

4 files changed

+140
-7
lines changed

4 files changed

+140
-7
lines changed

gcloud/datastore/connection.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ def run_query(self, dataset_id, query_pb, namespace=None):
186186
Under the hood this is doing...
187187
188188
>>> connection.run_query('dataset-id', query.to_protobuf())
189-
[<list of Entity Protobufs>]
189+
[<list of Entity Protobufs>], cursor, more_results, skipped_results
190190
191191
:type dataset_id: string
192192
:param dataset_id: The ID of the dataset over which to run the query.
@@ -205,7 +205,11 @@ def run_query(self, dataset_id, query_pb, namespace=None):
205205
request.query.CopyFrom(query_pb)
206206
response = self._rpc(dataset_id, 'runQuery', request,
207207
datastore_pb.RunQueryResponse)
208-
return [e.entity for e in response.batch.entity_result]
208+
return ([e.entity for e in response.batch.entity_result],
209+
response.batch.end_cursor,
210+
response.batch.more_results,
211+
response.batch.skipped_results,
212+
)
209213

210214
def lookup(self, dataset_id, key_pbs):
211215
"""Lookup keys from a dataset in the Cloud Datastore.

gcloud/datastore/query.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from gcloud.datastore import helpers
55
from gcloud.datastore.entity import Entity
66
from gcloud.datastore.key import Key
7+
import base64
78

89

910
class Query(object):
@@ -53,6 +54,7 @@ class Query(object):
5354
def __init__(self, kind=None, dataset=None):
5455
self._dataset = dataset
5556
self._pb = datastore_pb.Query()
57+
self._cursor = None
5658

5759
if kind:
5860
self._pb.kind.add().name = kind
@@ -303,12 +305,55 @@ def fetch(self, limit=None):
303305
if limit:
304306
clone = self.limit(limit)
305307

306-
entity_pbs = self.dataset().connection().run_query(
308+
(entity_pbs,
309+
end_cursor,
310+
more_results,
311+
skipped_results) = self.dataset().connection().run_query(
307312
query_pb=clone.to_protobuf(), dataset_id=self.dataset().id())
308313

314+
self._cursor = end_cursor
309315
return [Entity.from_protobuf(entity, dataset=self.dataset())
310316
for entity in entity_pbs]
311317

318+
def cursor(self):
319+
"""Returns cursor ID
320+
321+
.. Caution:: Invoking this method on a query that has not yet been
322+
executed will raise a RuntimeError.
323+
324+
:rtype: string
325+
:returns: base64-encoded cursor ID string denoting the last position
326+
consumed in the query's result set.
327+
"""
328+
if not self._cursor:
329+
raise RuntimeError('No cursor')
330+
return base64.b64encode(self._cursor)
331+
332+
def with_cursor(self, start_cursor, end_cursor=None):
333+
"""Specifies the starting / ending positions in a query's result set.
334+
335+
:type start_cursor: bytes
336+
:param start_cursor: Base64-encoded cursor string specifying where to
337+
start reading query results.
338+
339+
:type end_cursor: bytes
340+
:param end_cursor: Base64-encoded cursor string specifying where to stop
341+
reading query results.
342+
343+
:rtype: :class:`Query`
344+
:returns: If neither cursor is passed, returns self; else, returns a
345+
clone of the :class:`Query`, with cursors updated.
346+
347+
"""
348+
clone = self
349+
if start_cursor or end_cursor:
350+
clone = self._clone()
351+
if start_cursor:
352+
clone._pb.start_cursor = base64.b64decode(start_cursor)
353+
if end_cursor:
354+
clone._pb.end_cursor = base64.b64decode(end_cursor)
355+
return clone
356+
312357
def order(self, *properties):
313358
"""Adds a sort order to the query.
314359

gcloud/datastore/test_connection.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,11 @@ def test_run_query_wo_namespace_empty_result(self):
322322
'runQuery',
323323
])
324324
http = conn._http = Http({'status': '200'}, rsp_pb.SerializeToString())
325-
self.assertEqual(conn.run_query(DATASET_ID, q_pb), [])
325+
pbs, end, more, skipped = conn.run_query(DATASET_ID, q_pb)
326+
self.assertEqual(pbs, [])
327+
self.assertEqual(end, '')
328+
self.assertTrue(more)
329+
self.assertEqual(skipped, 0)
326330
cw = http._called_with
327331
self.assertEqual(cw['uri'], URI)
328332
self.assertEqual(cw['method'], 'POST')
@@ -357,8 +361,8 @@ def test_run_query_w_namespace_nonempty_result(self):
357361
'runQuery',
358362
])
359363
http = conn._http = Http({'status': '200'}, rsp_pb.SerializeToString())
360-
result = conn.run_query(DATASET_ID, q_pb, 'NS')
361-
returned, = result # One entity.
364+
pbs, end, more, skipped = conn.run_query(DATASET_ID, q_pb, 'NS')
365+
returned, = pbs, # One entity.
362366
cw = http._called_with
363367
self.assertEqual(cw['uri'], URI)
364368
self.assertEqual(cw['method'], 'POST')

gcloud/datastore/test_query.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ def test_fetch_default_limit(self):
202202

203203
def test_fetch_explicit_limit(self):
204204
from gcloud.datastore.datastore_v1_pb2 import Entity
205+
_CURSOR = 'CURSOR'
205206
_DATASET = 'DATASET'
206207
_KIND = 'KIND'
207208
_ID = 123
@@ -213,10 +214,12 @@ def test_fetch_explicit_limit(self):
213214
prop.name = 'foo'
214215
prop.value.string_value = 'Foo'
215216
connection = _Connection(entity_pb)
217+
connection._cursor = _CURSOR
216218
dataset = _Dataset(_DATASET, connection)
217219
query = self._makeOne(_KIND, dataset)
218220
limited = query.limit(13)
219221
entities = query.fetch(13)
222+
self.assertEqual(query._cursor, _CURSOR)
220223
self.assertEqual(len(entities), 1)
221224
self.assertEqual(entities[0].key().path(),
222225
[{'kind': _KIND, 'id': _ID}])
@@ -225,6 +228,80 @@ def test_fetch_explicit_limit(self):
225228
'query_pb': limited.to_protobuf(),
226229
})
227230

231+
def test_cursor_not_fetched(self):
232+
_DATASET = 'DATASET'
233+
_KIND = 'KIND'
234+
connection = _Connection()
235+
dataset = _Dataset(_DATASET, connection)
236+
query = self._makeOne(_KIND, dataset)
237+
self.assertRaises(RuntimeError, query.cursor)
238+
239+
def test_cursor_fetched(self):
240+
import base64
241+
_CURSOR = 'CURSOR'
242+
_DATASET = 'DATASET'
243+
_KIND = 'KIND'
244+
connection = _Connection()
245+
dataset = _Dataset(_DATASET, connection)
246+
query = self._makeOne(_KIND, dataset)
247+
query._cursor = _CURSOR
248+
self.assertEqual(query.cursor(), base64.b64encode(_CURSOR))
249+
250+
def test_with_cursor_neither(self):
251+
_DATASET = 'DATASET'
252+
_KIND = 'KIND'
253+
connection = _Connection()
254+
dataset = _Dataset(_DATASET, connection)
255+
query = self._makeOne(_KIND, dataset)
256+
self.assertTrue(query.with_cursor(None) is query)
257+
258+
def test_with_cursor_w_start(self):
259+
import base64
260+
_CURSOR = 'CURSOR'
261+
_CURSOR_B64 = base64.b64encode(_CURSOR)
262+
_DATASET = 'DATASET'
263+
_KIND = 'KIND'
264+
connection = _Connection()
265+
dataset = _Dataset(_DATASET, connection)
266+
query = self._makeOne(_KIND, dataset)
267+
after = query.with_cursor(_CURSOR_B64)
268+
self.assertFalse(after is query)
269+
q_pb = after.to_protobuf()
270+
self.assertEqual(q_pb.start_cursor, _CURSOR)
271+
self.assertEqual(q_pb.end_cursor, '')
272+
273+
def test_with_cursor_w_end(self):
274+
import base64
275+
_CURSOR = 'CURSOR'
276+
_CURSOR_B64 = base64.b64encode(_CURSOR)
277+
_DATASET = 'DATASET'
278+
_KIND = 'KIND'
279+
connection = _Connection()
280+
dataset = _Dataset(_DATASET, connection)
281+
query = self._makeOne(_KIND, dataset)
282+
after = query.with_cursor(None, _CURSOR_B64)
283+
self.assertFalse(after is query)
284+
q_pb = after.to_protobuf()
285+
self.assertEqual(q_pb.start_cursor, '')
286+
self.assertEqual(q_pb.end_cursor, _CURSOR)
287+
288+
def test_with_cursor_w_both(self):
289+
import base64
290+
_START = 'START'
291+
_START_B64 = base64.b64encode(_START)
292+
_END = 'CURSOR'
293+
_END_B64 = base64.b64encode(_END)
294+
_DATASET = 'DATASET'
295+
_KIND = 'KIND'
296+
connection = _Connection()
297+
dataset = _Dataset(_DATASET, connection)
298+
query = self._makeOne(_KIND, dataset)
299+
after = query.with_cursor(_START_B64, _END_B64)
300+
self.assertFalse(after is query)
301+
q_pb = after.to_protobuf()
302+
self.assertEqual(q_pb.start_cursor, _START)
303+
self.assertEqual(q_pb.end_cursor, _END)
304+
228305
def test_order_empty(self):
229306
_KIND = 'KIND'
230307
before = self._makeOne(_KIND)
@@ -285,10 +362,13 @@ def connection(self):
285362

286363
class _Connection(object):
287364
_called_with = None
365+
_cursor = ''
366+
_more = True
367+
_skipped = 0
288368

289369
def __init__(self, *result):
290370
self._result = list(result)
291371

292372
def run_query(self, **kw):
293373
self._called_with = kw
294-
return self._result
374+
return self._result, self._cursor, self._more, self._skipped

0 commit comments

Comments
 (0)