Skip to content

Commit 7627426

Browse files
rettichschnidiphanak-sap
authored andcommitted
service: Support null (#79)
Until now, null values received from OData servers get substituted by type specific default values. Users could not differentiate between those substitutes and actual values retrieved by the server, which might coincidentally equal the default ones inserted by pyodata. This commit allows the user to disable the substitution of null values, retrieving a None object instead. Example for Edm.Binary: | Server | pyodata old behavior | pyodata new (retain_null=True) | |--------+----------------------+--------------------------------| | 0 | 0 | 0 | | null | 0 | None |
1 parent 5e4b5a8 commit 7627426

File tree

6 files changed

+148
-6
lines changed

6 files changed

+148
-6
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
66

77
## [Unreleased]
88

9+
### Added
10+
- Prevent substitution of missing, nullable values - Reto Schneider
11+
912
### Fixed
1013
- Fix Increased robustness when schema with empty properties is returned
1114
- Use valid default value for Edm.DateTimeOffset - Reto Schneider

docs/usage/initialization.rst

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,30 @@ Additionally, Schema class has Boolean atribute 'is_valid' that returns if the p
164164
165165
northwind.schema.is_valid
166166
167+
Prevent substitution by default values
168+
--------------------------------------
169+
170+
Per default, missing properties get filled in by type specific default values. While convenient, this throws away
171+
the knowledge of whether a value was missing in the first place.
172+
To prevent this, the class config mentioned in the section above takes an additional parameter, `retain_null`.
173+
174+
.. code-block:: python
175+
176+
import pyodata
177+
import requests
178+
179+
SERVICE_URL = 'http://services.odata.org/V2/Northwind/Northwind.svc/'
180+
181+
northwind = pyodata.Client(SERVICE_URL, requests.Session(), config=pyodata.v2.model.Config(retain_null=True))
182+
183+
unknown_shipped_date = northwind.entity_sets.Orders_Qries.get_entity(OrderID=11058,
184+
CompanyName='Blauer See Delikatessen').execute()
185+
186+
print(
187+
f'Shipped date: {"unknown" if unknown_shipped_date.ShippedDate is None else unknown_shipped_date.ShippedDate}')
188+
189+
Changing `retain_null` to `False` will print `Shipped date: 1753-01-01 00:00:00+00:00`.
190+
167191
Set custom namespaces (Deprecated - use config instead)
168192
-------------------------------------------------------
169193

@@ -183,4 +207,3 @@ hosted on private urls such as *customEdmxUrl.com* and *customEdmUrl.com*:
183207
}
184208
185209
northwind = pyodata.Client(SERVICE_URL, requests.Session(), namespaces=namespaces)
186-

pyodata/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def __new__(cls, url, connection, odata_version=ODATA_VERSION_2, namespaces=None
6969

7070
# create service instance based on model we have
7171
logger.info('Creating OData Service (version: %d)', odata_version)
72-
service = pyodata.v2.service.Service(url, schema, connection)
72+
service = pyodata.v2.service.Service(url, schema, connection, config=config)
7373

7474
return service
7575

pyodata/v2/model.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ class Config:
9191
def __init__(self,
9292
custom_error_policies=None,
9393
default_error_policy=None,
94-
xml_namespaces=None):
94+
xml_namespaces=None,
95+
retain_null=False):
9596

9697
"""
9798
:param custom_error_policies: {ParserError: ErrorPolicy} (default None)
@@ -102,6 +103,9 @@ def __init__(self,
102103
If custom policy is not specified for the tag, the default policy will be used.
103104
104105
:param xml_namespaces: {str: str} (default None)
106+
107+
:param retain_null: bool (default False)
108+
If true, do not substitute missing (and null-able) values with default value.
105109
"""
106110

107111
self._custom_error_policy = custom_error_policies
@@ -116,6 +120,8 @@ def __init__(self,
116120

117121
self._namespaces = xml_namespaces
118122

123+
self._retain_null = retain_null
124+
119125
def err_policy(self, error: ParserError):
120126
if self._custom_error_policy is None:
121127
return self._default_error_policy
@@ -137,6 +143,10 @@ def namespaces(self):
137143
def namespaces(self, value: dict):
138144
self._namespaces = value
139145

146+
@property
147+
def retain_null(self):
148+
return self._retain_null
149+
140150

141151
class Identifier:
142152
def __init__(self, name):

pyodata/v2/service.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -734,7 +734,7 @@ class EntityProxy:
734734
named values), and links (references to other entities).
735735
"""
736736

737-
# pylint: disable=too-many-branches,too-many-nested-blocks
737+
# pylint: disable=too-many-branches,too-many-nested-blocks,too-many-statements
738738

739739
def __init__(self, service, entity_set, entity_type, proprties=None, entity_key=None, etag=None):
740740
self._logger = logging.getLogger(LOGGER_NAME)
@@ -761,11 +761,20 @@ def __init__(self, service, entity_set, entity_type, proprties=None, entity_key=
761761
# first, cache values of direct properties
762762
for type_proprty in self._entity_type.proprties():
763763
if type_proprty.name in proprties:
764+
# Property value available
764765
if proprties[type_proprty.name] is not None:
765766
self._cache[type_proprty.name] = type_proprty.from_json(proprties[type_proprty.name])
766-
else:
767+
continue
768+
# Property value missing and user wants a type specific default value filled in
769+
if not self._service.retain_null:
767770
# null value is in literal form for now, convert it to python representation
768771
self._cache[type_proprty.name] = type_proprty.from_literal(type_proprty.typ.null_value)
772+
continue
773+
# Property is nullable - save it as such
774+
if type_proprty.nullable:
775+
self._cache[type_proprty.name] = None
776+
continue
777+
raise PyODataException(f'Value of non-nullable Property {type_proprty.name} is null')
769778

770779
# then, assign all navigation properties
771780
for prop in self._entity_type.nav_proprties:
@@ -1581,10 +1590,11 @@ def function_import_handler(fimport, response):
15811590
class Service:
15821591
"""OData service"""
15831592

1584-
def __init__(self, url, schema, connection):
1593+
def __init__(self, url, schema, connection, config=None):
15851594
self._url = url
15861595
self._schema = schema
15871596
self._connection = connection
1597+
self._retain_null = config.retain_null if config else False
15881598
self._entity_container = EntityContainer(self)
15891599
self._function_container = FunctionContainer(self)
15901600

@@ -1608,6 +1618,12 @@ def connection(self):
16081618

16091619
return self._connection
16101620

1621+
@property
1622+
def retain_null(self):
1623+
"""Whether to respect null-ed values or to substitute them with type specific default values"""
1624+
1625+
return self._retain_null
1626+
16111627
@property
16121628
def entity_sets(self):
16131629
"""EntitySet proxy"""

tests/test_service_v2.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pyodata.v2.model
1010
import pyodata.v2.service
1111
from pyodata.exceptions import PyODataException, HttpError, ExpressionError, ProgramError, PyODataModelError
12+
from pyodata.v2 import model
1213
from pyodata.v2.service import EntityKey, EntityProxy, GetEntitySetFilter, ODataHttpResponse, HTTP_CODE_OK
1314

1415
from tests.conftest import assert_request_contains_header, contents_of_fixtures_file
@@ -24,6 +25,13 @@ def service(schema):
2425
return pyodata.v2.service.Service(URL_ROOT, schema, requests)
2526

2627

28+
@pytest.fixture
29+
def service_retain_null(schema):
30+
"""Service fixture which keeps null values as such"""
31+
assert schema.namespaces
32+
return pyodata.v2.service.Service(URL_ROOT, schema, requests, model.Config(retain_null=True))
33+
34+
2735
@responses.activate
2836
def test_create_entity(service):
2937
"""Basic test on creating entity"""
@@ -885,6 +893,88 @@ def test_get_entities(service):
885893
assert empls[0].NameFirst == 'Yennefer'
886894
assert empls[0].NameLast == 'De Vengerberg'
887895

896+
897+
@responses.activate
898+
def test_get_null_value_from_null_preserving_service(service_retain_null):
899+
"""Get entity with missing property value as None type"""
900+
901+
# pylint: disable=redefined-outer-name
902+
903+
responses.add(
904+
responses.GET,
905+
f"{service_retain_null.url}/Employees",
906+
json={'d': {
907+
'results': [
908+
{
909+
'ID': 1337,
910+
'NameFirst': 'Neo',
911+
'NameLast': None
912+
}
913+
]
914+
}},
915+
status=200)
916+
917+
request = service_retain_null.entity_sets.Employees.get_entities()
918+
919+
the_ones = request.execute()
920+
assert the_ones[0].ID == 1337
921+
assert the_ones[0].NameFirst == 'Neo'
922+
assert the_ones[0].NameLast is None
923+
924+
925+
@responses.activate
926+
def test_get_null_value_from_non_null_preserving_service(service):
927+
"""Get entity with missing property value as default type"""
928+
929+
# pylint: disable=redefined-outer-name
930+
931+
responses.add(
932+
responses.GET,
933+
f"{service.url}/Employees",
934+
json={'d': {
935+
'results': [
936+
{
937+
'ID': 1337,
938+
'NameFirst': 'Neo',
939+
'NameLast': None
940+
}
941+
]
942+
}},
943+
status=200)
944+
945+
request = service.entity_sets.Employees.get_entities()
946+
947+
the_ones = request.execute()
948+
assert the_ones[0].ID == 1337
949+
assert the_ones[0].NameFirst == 'Neo'
950+
assert the_ones[0].NameLast == ''
951+
952+
953+
@responses.activate
954+
def test_get_non_nullable_value(service_retain_null):
955+
"""Get error when receiving a null value for a non-nullable property"""
956+
957+
# pylint: disable=redefined-outer-name
958+
959+
responses.add(
960+
responses.GET,
961+
f"{service_retain_null.url}/Employees",
962+
json={'d': {
963+
'results': [
964+
{
965+
'ID': None,
966+
'NameFirst': 'Neo',
967+
}
968+
]
969+
}},
970+
status=200)
971+
972+
with pytest.raises(PyODataException) as e_info:
973+
service_retain_null.entity_sets.Employees.get_entities().execute()
974+
975+
assert str(e_info.value) == 'Value of non-nullable Property ID is null'
976+
977+
888978
@responses.activate
889979
def test_navigation_multi(service):
890980
"""Get entities via navigation property"""

0 commit comments

Comments
 (0)