From d47f9598cdccb4ea4ceaf956e34c962b4024d959 Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Fri, 2 Oct 2026 14:50:27 +0530 Subject: [PATCH] Fix iterable selections for entity and collection requests Normalize selection clauses through one helper while preserving strings and None. Fixes #71. --- CHANGELOG.md | 2 ++ docs/usage/querying.rst | 13 ++++++++++++ pyodata/v2/service.py | 15 ++++++++++---- tests/test_service_v2.py | 44 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0597a778..79e5e5b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed +- service: serialize iterable selections as comma-separated fields for entity and collection requests, including wildcard selections (#71, #73). + - service: `update_entity` accepts `200 OK` as well as `204 No Content`, so updates against services such as SAP SuccessFactors no longer raise `HttpError` (#136) - Sena Köse ## [1.12.1] diff --git a/docs/usage/querying.rst b/docs/usage/querying.rst index a55f9543..3811a34a 100644 --- a/docs/usage/querying.rst +++ b/docs/usage/querying.rst @@ -35,6 +35,19 @@ Print unique identification (Id) and last name of all employees: print(employee.EmployeeID, employee.LastName) +Selecting properties +-------------------- + +Both single-entity and entity-set requests accept comma-separated text or an +iterable of property names. Use ``'*'`` to request all properties, and ``None`` +to remove an existing selection: + +.. code-block:: python + + employee = northwind.entity_sets.Employees.get_entity(1).select(['EmployeeID', 'LastName']).execute() + employees = northwind.entity_sets.Employees.get_entities().select('*').execute() + + Get entities matching a filter ------------------------------ diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index ec58e204..cb4e7043 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -26,6 +26,13 @@ HTTP_CODE_NO_CONTENT = 204 +def _normalize_select(select): + """Convert an iterable of selection clauses to its query representation.""" + if select is None or isinstance(select, str): + return select + return ','.join(select) + + def urljoin(*path): """Joins the passed string parts into a one string url""" @@ -397,9 +404,9 @@ def nav(self, nav_property): def select(self, select): """Specifies a subset of properties to return. - @param select a comma-separated list of selection clauses + @param select comma-separated text or an iterable of selection clauses """ - self._select = select + self._select = _normalize_select(select) return self def expand(self, expand): @@ -710,8 +717,8 @@ def order_by(self, order_by): return self def select(self, select): - """Sets the selection clauses.""" - self._select = select + """Sets selection clauses from comma-separated text or an iterable.""" + self._select = _normalize_select(select) return self def skip(self, skip): diff --git a/tests/test_service_v2.py b/tests/test_service_v2.py index 276c9d99..832162e9 100644 --- a/tests/test_service_v2.py +++ b/tests/test_service_v2.py @@ -19,6 +19,48 @@ URL_ROOT = 'http://odatapy.example.com' +@pytest.mark.parametrize('single', [False, True]) +@pytest.mark.parametrize('make_selection', [list, tuple, iter]) +def test_select_iterable(service, single, make_selection): + """Iterable selections are serialized as one comma-separated query value.""" + request = (service.entity_sets.Employees.get_entity(23) if single + else service.entity_sets.Employees.get_entities()) + assert request.select(make_selection(['NameFirst', 'NameLast'])) is request + assert request.get_query_params()['$select'] == 'NameFirst,NameLast' + # A generator must not be consumed again while building another request. + assert request.get_query_params()['$select'] == 'NameFirst,NameLast' + + +@pytest.mark.parametrize('single', [False, True]) +def test_select_string_and_reset(service, single): + """String selections and clearing an existing selection remain supported.""" + request = (service.entity_sets.Employees.get_entity(23) if single + else service.entity_sets.Employees.get_entities()) + assert request.select('NameFirst,NameLast') is request + assert request.get_query_params()['$select'] == 'NameFirst,NameLast' + assert request.select(None) is request + assert '$select' not in request.get_query_params() + + +@pytest.mark.parametrize('single', [False, True]) +@pytest.mark.parametrize('selection', ['*', ['*'], ('*',), {'*'}]) +def test_select_wildcard(service, single, selection): + """Both request types preserve the OData all-properties selection.""" + request = (service.entity_sets.Employees.get_entity(23) if single + else service.entity_sets.Employees.get_entities()) + assert request.select(selection) is request + assert request.get_query_params()['$select'] == '*' + + +@pytest.mark.parametrize('single', [False, True]) +def test_select_set(service, single): + """Unordered selection inputs retain every requested property.""" + request = (service.entity_sets.Employees.get_entity(23) if single + else service.entity_sets.Employees.get_entities()) + request.select({'NameFirst', 'NameLast'}) + assert set(request.get_query_params()['$select'].split(',')) == {'NameFirst', 'NameLast'} + + @pytest.fixture def service(schema): """Service fixture""" @@ -3204,4 +3246,4 @@ def hook(response): def test_service_without_response_hook_works(service): """response_hook defaults to None and does not affect normal operation""" - assert service.response_hook is None \ No newline at end of file + assert service.response_hook is None