Skip to content

Commit 41abb98

Browse files
rettichschnidifilak-sap
authored andcommitted
service: Support for partial listings (server-side pagination)
Reading the spec, using the __next field, not $top/$skip/__count seems to be the pristine way of fetching an entity collection in full: > In response payloads, representing Collections of Entries, if the > server does not include an object for every Entry in the Collection of > Entries identified by the request URI then the response represents a > partial listings of the Collection. In this case, "__next" name/value > pair is included to indicate the response represents a partial > listing. The value of the name/value pair is a URI which identifies > the next partial set of entities from the originally identified > complete set.
1 parent 18d4b9e commit 41abb98

File tree

2 files changed

+98
-5
lines changed

2 files changed

+98
-5
lines changed

pyodata/v2/service.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ def __init__(self, url, connection, handler, headers=None):
238238
self._headers = headers or dict()
239239
self._logger = logging.getLogger(LOGGER_NAME)
240240
self._customs = {} # string -> string hash
241+
self._next_url = None
241242

242243
@property
243244
def handler(self):
@@ -299,7 +300,10 @@ def execute(self):
299300
300301
Fetches HTTP response and returns processed result"""
301302

302-
url = urljoin(self._url, self.get_path())
303+
if self._next_url:
304+
url = self._next_url
305+
else:
306+
url = urljoin(self._url, self.get_path())
303307
# pylint: disable=assignment-from-none
304308
body = self.get_body()
305309

@@ -616,6 +620,17 @@ def count(self, inline=False):
616620
self._count = True
617621
return self
618622

623+
def next_url(self, next_url):
624+
"""
625+
Sets URL which identifies the next partial set of entities from the originally identified complete set. Once
626+
set, this URL takes precedence over all query parameters.
627+
628+
For details, see section "6. Representing Collections of Entries" on
629+
https://www.odata.org/documentation/odata-version-2-0/json-format/
630+
"""
631+
self._next_url = next_url
632+
return self
633+
619634
def expand(self, expand):
620635
"""Sets the expand expressions."""
621636
self._expand = expand
@@ -667,6 +682,9 @@ def get_default_headers(self):
667682
}
668683

669684
def get_query_params(self):
685+
if self._next_url:
686+
return {}
687+
670688
qparams = super(QueryRequest, self).get_query_params()
671689

672690
if self._top is not None:
@@ -1250,11 +1268,24 @@ def filter(self, *args, **kwargs):
12501268

12511269

12521270
class ListWithTotalCount(list):
1253-
"""A list with the additional property total_count"""
1271+
"""
1272+
A list with the additional property total_count and next_url.
1273+
1274+
If set, use next_url to fetch the next batch of entities.
1275+
"""
12541276

1255-
def __init__(self, total_count):
1277+
def __init__(self, total_count, next_url):
12561278
super(ListWithTotalCount, self).__init__()
12571279
self._total_count = total_count
1280+
self._next_url = next_url
1281+
1282+
@property
1283+
def next_url(self):
1284+
"""
1285+
URL which identifies the next partial set of entities from the originally identified complete set. None if no
1286+
entities remaining.
1287+
"""
1288+
return self._next_url
12581289

12591290
@property
12601291
def total_count(self):
@@ -1390,7 +1421,8 @@ def get_entity_handler(response):
13901421
return EntityGetRequest(get_entity_handler, entity_key, self)
13911422

13921423
def get_entities(self):
1393-
"""Get all entities"""
1424+
"""Get some, potentially all entities"""
1425+
13941426
def get_entities_handler(response):
13951427
"""Gets entity set from HTTP Response"""
13961428

@@ -1405,15 +1437,18 @@ def get_entities_handler(response):
14051437

14061438
entities = content['d']
14071439
total_count = None
1440+
next_url = None
14081441

14091442
if isinstance(entities, dict):
14101443
if '__count' in entities:
14111444
total_count = int(entities['__count'])
1445+
if '__next' in entities:
1446+
next_url = entities['__next']
14121447
entities = entities['results']
14131448

14141449
self._logger.info('Fetched %d entities', len(entities))
14151450

1416-
result = ListWithTotalCount(total_count)
1451+
result = ListWithTotalCount(total_count, next_url)
14171452
for props in entities:
14181453
entity = EntityProxy(self._service, self._entity_set, self._entity_set.entity_type, props)
14191454
result.append(entity)

tests/test_service_v2.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2005,6 +2005,64 @@ def test_count_with_chainable_filter(service):
20052005
assert request.execute() == 3
20062006

20072007

2008+
@responses.activate
2009+
def test_partial_listing(service):
2010+
"""Using __next URI to fetch all entities in a collection"""
2011+
2012+
# pylint: disable=redefined-outer-name
2013+
2014+
responses.add(
2015+
responses.GET,
2016+
f"{service.url}/Employees?$inlinecount=allpages",
2017+
json={'d': {
2018+
'__count': 3,
2019+
'__next': f"{service.url}/Employees?$inlinecount=allpages&$skiptoken='opaque'",
2020+
'results': [
2021+
{
2022+
'ID': 21,
2023+
'NameFirst': 'George',
2024+
'NameLast': 'Doe'
2025+
},{
2026+
'ID': 22,
2027+
'NameFirst': 'John',
2028+
'NameLast': 'Doe'
2029+
}
2030+
]
2031+
}},
2032+
status=200)
2033+
2034+
responses.add(
2035+
responses.GET,
2036+
f"{service.url}/Employees?$inlinecount=allpages&$skiptoken='opaque'",
2037+
json={'d': {
2038+
'__count': 3,
2039+
'results': [
2040+
{
2041+
'ID': 23,
2042+
'NameFirst': 'Rob',
2043+
'NameLast': 'Ickes'
2044+
}
2045+
]
2046+
}},
2047+
status=200)
2048+
2049+
# Fetching (potentially) all entities, actually getting 2
2050+
request = service.entity_sets.Employees.get_entities().count(inline=True)
2051+
assert isinstance(request, pyodata.v2.service.GetEntitySetRequest)
2052+
result = request.execute()
2053+
assert len(result) == 2
2054+
assert result.total_count == 3
2055+
assert result.next_url is not None
2056+
2057+
# Fetching next batch, receive the one remaining entity
2058+
request = service.entity_sets.Employees.get_entities().next_url(result.next_url)
2059+
assert isinstance(request, pyodata.v2.service.GetEntitySetRequest)
2060+
result = request.execute()
2061+
assert len(result) == 1
2062+
assert result.total_count == 3, "(inline) count flag inherited from first request"
2063+
assert result.next_url is None
2064+
2065+
20082066
@responses.activate
20092067
def test_count_with_chainable_filter_lt_operator(service):
20102068
"""Check getting $count with $filter with new filter syntax using multiple filters"""

0 commit comments

Comments
 (0)