Skip to content

Commit cc8c71a

Browse files
committed
tree-wide: correctly handle null values
Only a declared property knows if it accepts Null or not. I was playing with nullable Type but than I would have to have also nullable TypeTraits and there result would be a complete mess. Therefore I decided to move the responsibility to serialize/deserialize the declared property. The decision led to a simpler code which is a good sign. I could have used a better exception type but we currently evaluating the best exception strategies, so I used a generic type. Part of the issue SAP#95
1 parent 611dc48 commit cc8c71a

5 files changed

+222
-13
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1616
### Fixed
1717
- removed superfluous debug print when parsing FunctionImports from metadata - Jakub Filak
1818
- use correct type of deserialization of Literal (URL) structure values - Jakub Filak
19+
- null values are correctly handled and nullable configuration is honored - Jakub Filak
1920

2021
## [1.4.0]
2122

pyodata/v2/model.py

+39-3
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ def to_literal(edm_type, value):
276276
result = {}
277277
for type_prop in edm_type.proprties():
278278
if type_prop.name in value:
279-
result[type_prop.name] = type_prop.typ.traits.to_literal(value[type_prop.name])
279+
result[type_prop.name] = type_prop.to_literal(value[type_prop.name])
280280

281281
return result
282282

@@ -290,7 +290,7 @@ def from_json(edm_type, value):
290290
result = {}
291291
for type_prop in edm_type.proprties():
292292
if type_prop.name in value:
293-
result[type_prop.name] = type_prop.typ.traits.from_json(value[type_prop.name])
293+
result[type_prop.name] = type_prop.from_json(value[type_prop.name])
294294

295295
return result
296296

@@ -304,7 +304,7 @@ def from_literal(edm_type, value):
304304
result = {}
305305
for type_prop in edm_type.proprties():
306306
if type_prop.name in value:
307-
result[type_prop.name] = type_prop.typ.traits.from_literal(value[type_prop.name])
307+
result[type_prop.name] = type_prop.from_literal(value[type_prop.name])
308308

309309
return result
310310

@@ -700,6 +700,42 @@ def precision(self):
700700
def scale(self):
701701
return self._scale
702702

703+
def from_literal(self, value):
704+
if value is None:
705+
if not self.nullable:
706+
raise PyODataException(f'Cannot convert null URL literal to value of {str(self)}')
707+
708+
return None
709+
710+
return self.typ.traits.from_literal(value)
711+
712+
def to_literal(self, value):
713+
if value is None:
714+
if not self.nullable:
715+
raise PyODataException(f'Cannot convert None to URL literal of {str(self)}')
716+
717+
return None
718+
719+
return self.typ.traits.to_literal(value)
720+
721+
def from_json(self, value):
722+
if value is None:
723+
if not self.nullable:
724+
raise PyODataException(f'Cannot convert null JSON to value of {str(self)}')
725+
726+
return None
727+
728+
return self.typ.traits.from_json(value)
729+
730+
def to_json(self, value):
731+
if value is None:
732+
if not self.nullable:
733+
raise PyODataException(f'Cannot convert None to JSON of {str(self)}')
734+
735+
return None
736+
737+
return self.typ.traits.to_json(value)
738+
703739
def _check_scale_value(self):
704740
if self._scale > self._precision:
705741
raise PyODataModelError('Scale value ({}) must be less than or equal to precision value ({})'

pyodata/v2/service.py

+9-10
Original file line numberDiff line numberDiff line change
@@ -204,15 +204,15 @@ def to_key_string_without_parentheses(self):
204204
if self._type == EntityKey.TYPE_SINGLE:
205205
# first property is the key property
206206
key_prop = self._key[0]
207-
return key_prop.typ.traits.to_literal(self._proprties[key_prop.name])
207+
return key_prop.to_literal(self._proprties[key_prop.name])
208208

209209
key_pairs = []
210210
for key_prop in self._key:
211211
# if key_prop.name not in self.__dict__['_cache']:
212212
# raise RuntimeError('Entity key is not complete, missing value of property: {0}'.format(key_prop.name))
213213

214214
key_pairs.append(
215-
'{0}={1}'.format(key_prop.name, key_prop.typ.traits.to_literal(self._proprties[key_prop.name])))
215+
'{0}={1}'.format(key_prop.name, key_prop.to_literal(self._proprties[key_prop.name])))
216216

217217
return ','.join(key_pairs)
218218

@@ -456,7 +456,7 @@ def _build_values(entity_type, entity):
456456
values = {}
457457
for key, val in entity.items():
458458
try:
459-
val = entity_type.proprty(key).typ.traits.to_json(val)
459+
val = entity_type.proprty(key).to_json(val)
460460
except KeyError:
461461
try:
462462
nav_prop = entity_type.nav_proprty(key)
@@ -543,7 +543,7 @@ def set(self, **kwargs):
543543

544544
for key, val in kwargs.items():
545545
try:
546-
val = self._entity_type.proprty(key).typ.traits.to_json(val)
546+
val = self._entity_type.proprty(key).to_json(val)
547547
except KeyError:
548548
raise PyODataException(
549549
'Property {} is not declared in {} entity type'.format(key, self._entity_type.name))
@@ -679,7 +679,7 @@ def parameter(self, name, value):
679679
param = self._function_import.get_parameter(name)
680680

681681
# add parameter as custom query argument
682-
self.custom(param.name, param.typ.traits.to_literal(value))
682+
self.custom(param.name, param.to_literal(value))
683683
except KeyError:
684684
raise PyODataException('Function import {0} does not have pararmeter {1}'
685685
.format(self._function_import.name, name))
@@ -721,11 +721,10 @@ def __init__(self, service, entity_set, entity_type, proprties=None, entity_key=
721721
for type_proprty in self._entity_type.proprties():
722722
if type_proprty.name in proprties:
723723
if proprties[type_proprty.name] is not None:
724-
self._cache[type_proprty.name] = type_proprty.typ.traits.from_json(proprties[type_proprty.name])
724+
self._cache[type_proprty.name] = type_proprty.from_json(proprties[type_proprty.name])
725725
else:
726726
# null value is in literal form for now, convert it to python representation
727-
self._cache[type_proprty.name] = type_proprty.typ.traits.from_literal(
728-
type_proprty.typ.null_value)
727+
self._cache[type_proprty.name] = type_proprty.from_literal(type_proprty.typ.null_value)
729728

730729
# then, assign all navigation properties
731730
for prop in self._entity_type.nav_proprties:
@@ -843,7 +842,7 @@ def proprty_get_handler(key, proprty, response):
843842
.format(proprty.name, key, response.status_code), response)
844843

845844
data = response.json()['d']
846-
return proprty.typ.traits.from_json(data[proprty.name])
845+
return proprty.from_json(data[proprty.name])
847846

848847
path = urljoin(self.get_path(), name)
849848
return self._service.http_get_odata(
@@ -942,7 +941,7 @@ def or_(*operands):
942941
def format_filter(proprty, operator, value):
943942
"""Creates a filter expression """
944943

945-
return '{} {} {}'.format(proprty.name, operator, proprty.typ.traits.to_literal(value))
944+
return '{} {} {}'.format(proprty.name, operator, proprty.to_literal(value))
946945

947946
def __eq__(self, value):
948947
return GetEntitySetFilter.format_filter(self._proprty, 'eq', value)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Tests of OData Model: class VariableDeclaration"""
2+
3+
import pytest
4+
import datetime
5+
from pyodata.v2.model import EdmStructTypeSerializer, Types, StructType, StructTypeProperty
6+
from pyodata.exceptions import PyODataException
7+
8+
9+
@pytest.fixture
10+
def complex_type_property_declarations():
11+
return {
12+
'TestString': (Types.parse_type_name('Edm.String'), "'FooBar'", "'FooBar'", 'FooBar'),
13+
'TestBoolean': (Types.parse_type_name('Edm.Boolean'), False, 'false', False),
14+
'TestInt64': (Types.parse_type_name('Edm.Int64'), '123L', '123L', 123),
15+
'TestDateTime': (Types.parse_type_name('Edm.DateTime'), "/Date(2147483647000)/", "datetime'2038-01-19T3:14:7'",
16+
datetime.datetime(2038, 1, 19, hour=3, minute=14, second=7, tzinfo=datetime.timezone.utc))
17+
}
18+
19+
20+
def define_complex_type(complex_type_property_declarations, nullable = True):
21+
complex_typ = StructType('TestComplexType', 'Label Complex Type', False)
22+
23+
for name, prop_decl in complex_type_property_declarations.items():
24+
prop = StructTypeProperty(name, prop_decl[0], nullable, None, None, None,
25+
None, None, None, None, None, None, None, None, None, None, None, None)
26+
27+
prop.typ = Types.from_name(prop.type_info.name)
28+
complex_typ._properties[prop.name] = prop
29+
prop.struct_type = complex_typ
30+
31+
return complex_typ
32+
33+
34+
@pytest.fixture
35+
def complex_type_with_nullable_props(complex_type_property_declarations, nullable = True):
36+
return define_complex_type(complex_type_property_declarations, nullable=True)
37+
38+
39+
@pytest.fixture
40+
def complex_type_without_nullable_props(complex_type_property_declarations, nullable = True):
41+
return define_complex_type(complex_type_property_declarations, nullable=False)
42+
43+
44+
def test_nullable_from_json_null_properties(complex_type_with_nullable_props, complex_type_property_declarations):
45+
entity_json = { prop_name: None for prop_name in complex_type_property_declarations.keys() }
46+
47+
entity_odata = complex_type_with_nullable_props.traits.from_json(entity_json)
48+
49+
assert entity_json.keys() == entity_odata.keys()
50+
51+
for name, value in entity_odata.items():
52+
assert value is None, f'Property: {name}'
53+
54+
55+
def test_non_nullable_from_json_null_properties(complex_type_without_nullable_props, complex_type_property_declarations):
56+
for prop_name in complex_type_property_declarations.keys():
57+
entity_json = { prop_name : None }
58+
with pytest.raises(PyODataException):
59+
entity_odata = complex_type_without_nullable_props.traits.from_json(entity_json)
60+
61+
62+
def test_non_nullable_from_json(complex_type_without_nullable_props, complex_type_property_declarations):
63+
entity_json = { prop_name : prop_decl[1] for prop_name, prop_decl in complex_type_property_declarations.items() }
64+
65+
entity_odata =complex_type_without_nullable_props.traits.from_json(entity_json)
66+
67+
assert entity_json.keys() == entity_odata.keys()
68+
69+
for name, value in entity_odata.items():
70+
assert value == complex_type_property_declarations[name][3], f'Value of {name}'
71+
72+
73+
def test_nullable_from_literal_null_properties(complex_type_with_nullable_props, complex_type_property_declarations):
74+
entity_literal = { prop_name: None for prop_name in complex_type_property_declarations.keys() }
75+
76+
entity_odata = complex_type_with_nullable_props.traits.from_literal(entity_literal)
77+
78+
assert entity_literal.keys() == entity_odata.keys()
79+
80+
for name, value in entity_odata.items():
81+
assert value is None, f'Property: {name}'
82+
83+
84+
def test_non_nullable_from_literal_null_properties(complex_type_without_nullable_props, complex_type_property_declarations):
85+
for prop_name in complex_type_property_declarations.keys():
86+
entity_literal = { prop_name : None }
87+
with pytest.raises(PyODataException):
88+
entity_odata = complex_type_without_nullable_props.traits.from_literal(entity_literal)
89+
90+
91+
def test_non_nullable_from_literal(complex_type_without_nullable_props, complex_type_property_declarations):
92+
entity_literal = { prop_name : prop_decl[2] for prop_name, prop_decl in complex_type_property_declarations.items() }
93+
94+
entity_odata =complex_type_without_nullable_props.traits.from_literal(entity_literal)
95+
96+
assert entity_literal.keys() == entity_odata.keys()
97+
98+
for name, value in entity_odata.items():
99+
assert value == complex_type_property_declarations[name][3], f'Value of {name}'
+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Tests of OData Model: class VariableDeclaration"""
2+
3+
import pytest
4+
from pyodata.v2.model import VariableDeclaration, Types
5+
from pyodata.exceptions import PyODataException
6+
7+
8+
@pytest.fixture
9+
def variable_of_string_nullable():
10+
variable = VariableDeclaration('TestVariable', Types.parse_type_name('Edm.String'), True, None, None, None)
11+
variable.typ = Types.from_name(variable.type_info.name)
12+
return variable
13+
14+
@pytest.fixture
15+
def variable_of_string():
16+
variable = VariableDeclaration('TestVariable', Types.parse_type_name('Edm.String'), False, None, None, None)
17+
variable.typ = Types.from_name(variable.type_info.name)
18+
return variable
19+
20+
21+
def test_variable_of_string_nullable_from_json_none(variable_of_string_nullable):
22+
assert variable_of_string_nullable.from_json(None) is None
23+
24+
25+
def test_variable_of_string_nullable_to_json_none(variable_of_string_nullable):
26+
assert variable_of_string_nullable.to_json(None) is None
27+
28+
29+
def test_variable_of_string_nullable_from_literal_none(variable_of_string_nullable):
30+
assert variable_of_string_nullable.from_literal(None) is None
31+
32+
33+
def test_variable_of_string_nullable_to_literal_none(variable_of_string_nullable):
34+
assert variable_of_string_nullable.to_literal(None) is None
35+
36+
37+
def test_variable_of_string_nullable_from_json_non_none(variable_of_string_nullable):
38+
assert variable_of_string_nullable.from_json('FromJSON') == 'FromJSON'
39+
40+
41+
def test_variable_of_string_nullable_to_json(variable_of_string_nullable):
42+
assert variable_of_string_nullable.to_json('ToJSON') == 'ToJSON'
43+
44+
45+
def test_variable_of_string_nullable_from_literal(variable_of_string_nullable):
46+
assert variable_of_string_nullable.from_literal("'FromLiteral'") == 'FromLiteral'
47+
48+
49+
def test_variable_of_string_nullable_to_literal(variable_of_string_nullable):
50+
assert variable_of_string_nullable.to_literal('ToLiteral') == "'ToLiteral'"
51+
52+
53+
def test_variable_of_string_from_json_none(variable_of_string):
54+
with pytest.raises(PyODataException) as e_info:
55+
variable_of_string.from_json(None)
56+
assert str(e_info.value).startswith('Cannot convert null JSON to value of VariableDeclaration(TestVariable)')
57+
58+
59+
def test_variable_of_string_to_json_none(variable_of_string):
60+
with pytest.raises(PyODataException) as e_info:
61+
variable_of_string.to_json(None)
62+
assert str(e_info.value).startswith('Cannot convert None to JSON of VariableDeclaration(TestVariable)')
63+
64+
65+
def test_variable_of_string_from_literal_none(variable_of_string):
66+
with pytest.raises(PyODataException) as e_info:
67+
variable_of_string.from_literal(None)
68+
assert str(e_info.value).startswith('Cannot convert null URL literal to value of VariableDeclaration(TestVariable)')
69+
70+
71+
def test_variable_of_string_to_literal_none(variable_of_string):
72+
with pytest.raises(PyODataException) as e_info:
73+
variable_of_string.to_literal(None)
74+
assert str(e_info.value).startswith('Cannot convert None to URL literal of VariableDeclaration(TestVariable)')

0 commit comments

Comments
 (0)