Skip to content

Commit

Permalink
tree-wide: correctly handle null values
Browse files Browse the repository at this point in the history
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 #95
  • Loading branch information
filak-sap committed Apr 30, 2020
1 parent 334aa03 commit 7972f31
Show file tree
Hide file tree
Showing 5 changed files with 222 additions and 13 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- removed superfluous debug print when parsing FunctionImports from metadata - Jakub Filak
- property 'Nullable' attributes are correctly parsed and respected - Vasilii Khomutov
- use correct type of deserialization of Literal (URL) structure values - Jakub Filak
- null values are correctly handled - Jakub Filak

## [1.4.0]

Expand Down
42 changes: 39 additions & 3 deletions pyodata/v2/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ def to_literal(edm_type, value):
result = {}
for type_prop in edm_type.proprties():
if type_prop.name in value:
result[type_prop.name] = type_prop.typ.traits.to_literal(value[type_prop.name])
result[type_prop.name] = type_prop.to_literal(value[type_prop.name])

return result

Expand All @@ -301,7 +301,7 @@ def from_json(edm_type, value):
result = {}
for type_prop in edm_type.proprties():
if type_prop.name in value:
result[type_prop.name] = type_prop.typ.traits.from_json(value[type_prop.name])
result[type_prop.name] = type_prop.from_json(value[type_prop.name])

return result

Expand All @@ -315,7 +315,7 @@ def from_literal(edm_type, value):
result = {}
for type_prop in edm_type.proprties():
if type_prop.name in value:
result[type_prop.name] = type_prop.typ.traits.from_literal(value[type_prop.name])
result[type_prop.name] = type_prop.from_literal(value[type_prop.name])

return result

Expand Down Expand Up @@ -711,6 +711,42 @@ def precision(self):
def scale(self):
return self._scale

def from_literal(self, value):
if value is None:
if not self.nullable:
raise PyODataException(f'Cannot convert null URL literal to value of {str(self)}')

return None

return self.typ.traits.from_literal(value)

def to_literal(self, value):
if value is None:
if not self.nullable:
raise PyODataException(f'Cannot convert None to URL literal of {str(self)}')

return None

return self.typ.traits.to_literal(value)

def from_json(self, value):
if value is None:
if not self.nullable:
raise PyODataException(f'Cannot convert null JSON to value of {str(self)}')

return None

return self.typ.traits.from_json(value)

def to_json(self, value):
if value is None:
if not self.nullable:
raise PyODataException(f'Cannot convert None to JSON of {str(self)}')

return None

return self.typ.traits.to_json(value)

def _check_scale_value(self):
if self._scale > self._precision:
raise PyODataModelError('Scale value ({}) must be less than or equal to precision value ({})'
Expand Down
19 changes: 9 additions & 10 deletions pyodata/v2/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,15 +204,15 @@ def to_key_string_without_parentheses(self):
if self._type == EntityKey.TYPE_SINGLE:
# first property is the key property
key_prop = self._key[0]
return key_prop.typ.traits.to_literal(self._proprties[key_prop.name])
return key_prop.to_literal(self._proprties[key_prop.name])

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

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

return ','.join(key_pairs)

Expand Down Expand Up @@ -456,7 +456,7 @@ def _build_values(entity_type, entity):
values = {}
for key, val in entity.items():
try:
val = entity_type.proprty(key).typ.traits.to_json(val)
val = entity_type.proprty(key).to_json(val)
except KeyError:
try:
nav_prop = entity_type.nav_proprty(key)
Expand Down Expand Up @@ -543,7 +543,7 @@ def set(self, **kwargs):

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

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

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

data = response.json()['d']
return proprty.typ.traits.from_json(data[proprty.name])
return proprty.from_json(data[proprty.name])

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

return '{} {} {}'.format(proprty.name, operator, proprty.typ.traits.to_literal(value))
return '{} {} {}'.format(proprty.name, operator, proprty.to_literal(value))

def __eq__(self, value):
return GetEntitySetFilter.format_filter(self._proprty, 'eq', value)
Expand Down
99 changes: 99 additions & 0 deletions tests/test_model_v2_EdmStructTypeSerializer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Tests of OData Model: class VariableDeclaration"""

import pytest
import datetime
from pyodata.v2.model import EdmStructTypeSerializer, Types, StructType, StructTypeProperty
from pyodata.exceptions import PyODataException


@pytest.fixture
def complex_type_property_declarations():
return {
'TestString': (Types.parse_type_name('Edm.String'), "'FooBar'", "'FooBar'", 'FooBar'),
'TestBoolean': (Types.parse_type_name('Edm.Boolean'), False, 'false', False),
'TestInt64': (Types.parse_type_name('Edm.Int64'), '123L', '123L', 123),
'TestDateTime': (Types.parse_type_name('Edm.DateTime'), "/Date(2147483647000)/", "datetime'2038-01-19T3:14:7'",
datetime.datetime(2038, 1, 19, hour=3, minute=14, second=7, tzinfo=datetime.timezone.utc))
}


def define_complex_type(complex_type_property_declarations, nullable = True):
complex_typ = StructType('TestComplexType', 'Label Complex Type', False)

for name, prop_decl in complex_type_property_declarations.items():
prop = StructTypeProperty(name, prop_decl[0], nullable, None, None, None,
None, None, None, None, None, None, None, None, None, None, None, None)

prop.typ = Types.from_name(prop.type_info.name)
complex_typ._properties[prop.name] = prop
prop.struct_type = complex_typ

return complex_typ


@pytest.fixture
def complex_type_with_nullable_props(complex_type_property_declarations, nullable = True):
return define_complex_type(complex_type_property_declarations, nullable=True)


@pytest.fixture
def complex_type_without_nullable_props(complex_type_property_declarations, nullable = True):
return define_complex_type(complex_type_property_declarations, nullable=False)


def test_nullable_from_json_null_properties(complex_type_with_nullable_props, complex_type_property_declarations):
entity_json = { prop_name: None for prop_name in complex_type_property_declarations.keys() }

entity_odata = complex_type_with_nullable_props.traits.from_json(entity_json)

assert entity_json.keys() == entity_odata.keys()

for name, value in entity_odata.items():
assert value is None, f'Property: {name}'


def test_non_nullable_from_json_null_properties(complex_type_without_nullable_props, complex_type_property_declarations):
for prop_name in complex_type_property_declarations.keys():
entity_json = { prop_name : None }
with pytest.raises(PyODataException):
entity_odata = complex_type_without_nullable_props.traits.from_json(entity_json)


def test_non_nullable_from_json(complex_type_without_nullable_props, complex_type_property_declarations):
entity_json = { prop_name : prop_decl[1] for prop_name, prop_decl in complex_type_property_declarations.items() }

entity_odata =complex_type_without_nullable_props.traits.from_json(entity_json)

assert entity_json.keys() == entity_odata.keys()

for name, value in entity_odata.items():
assert value == complex_type_property_declarations[name][3], f'Value of {name}'


def test_nullable_from_literal_null_properties(complex_type_with_nullable_props, complex_type_property_declarations):
entity_literal = { prop_name: None for prop_name in complex_type_property_declarations.keys() }

entity_odata = complex_type_with_nullable_props.traits.from_literal(entity_literal)

assert entity_literal.keys() == entity_odata.keys()

for name, value in entity_odata.items():
assert value is None, f'Property: {name}'


def test_non_nullable_from_literal_null_properties(complex_type_without_nullable_props, complex_type_property_declarations):
for prop_name in complex_type_property_declarations.keys():
entity_literal = { prop_name : None }
with pytest.raises(PyODataException):
entity_odata = complex_type_without_nullable_props.traits.from_literal(entity_literal)


def test_non_nullable_from_literal(complex_type_without_nullable_props, complex_type_property_declarations):
entity_literal = { prop_name : prop_decl[2] for prop_name, prop_decl in complex_type_property_declarations.items() }

entity_odata =complex_type_without_nullable_props.traits.from_literal(entity_literal)

assert entity_literal.keys() == entity_odata.keys()

for name, value in entity_odata.items():
assert value == complex_type_property_declarations[name][3], f'Value of {name}'
74 changes: 74 additions & 0 deletions tests/test_model_v2_VariableDeclaration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Tests of OData Model: class VariableDeclaration"""

import pytest
from pyodata.v2.model import VariableDeclaration, Types
from pyodata.exceptions import PyODataException


@pytest.fixture
def variable_of_string_nullable():
variable = VariableDeclaration('TestVariable', Types.parse_type_name('Edm.String'), True, None, None, None)
variable.typ = Types.from_name(variable.type_info.name)
return variable

@pytest.fixture
def variable_of_string():
variable = VariableDeclaration('TestVariable', Types.parse_type_name('Edm.String'), False, None, None, None)
variable.typ = Types.from_name(variable.type_info.name)
return variable


def test_variable_of_string_nullable_from_json_none(variable_of_string_nullable):
assert variable_of_string_nullable.from_json(None) is None


def test_variable_of_string_nullable_to_json_none(variable_of_string_nullable):
assert variable_of_string_nullable.to_json(None) is None


def test_variable_of_string_nullable_from_literal_none(variable_of_string_nullable):
assert variable_of_string_nullable.from_literal(None) is None


def test_variable_of_string_nullable_to_literal_none(variable_of_string_nullable):
assert variable_of_string_nullable.to_literal(None) is None


def test_variable_of_string_nullable_from_json_non_none(variable_of_string_nullable):
assert variable_of_string_nullable.from_json('FromJSON') == 'FromJSON'


def test_variable_of_string_nullable_to_json(variable_of_string_nullable):
assert variable_of_string_nullable.to_json('ToJSON') == 'ToJSON'


def test_variable_of_string_nullable_from_literal(variable_of_string_nullable):
assert variable_of_string_nullable.from_literal("'FromLiteral'") == 'FromLiteral'


def test_variable_of_string_nullable_to_literal(variable_of_string_nullable):
assert variable_of_string_nullable.to_literal('ToLiteral') == "'ToLiteral'"


def test_variable_of_string_from_json_none(variable_of_string):
with pytest.raises(PyODataException) as e_info:
variable_of_string.from_json(None)
assert str(e_info.value).startswith('Cannot convert null JSON to value of VariableDeclaration(TestVariable)')


def test_variable_of_string_to_json_none(variable_of_string):
with pytest.raises(PyODataException) as e_info:
variable_of_string.to_json(None)
assert str(e_info.value).startswith('Cannot convert None to JSON of VariableDeclaration(TestVariable)')


def test_variable_of_string_from_literal_none(variable_of_string):
with pytest.raises(PyODataException) as e_info:
variable_of_string.from_literal(None)
assert str(e_info.value).startswith('Cannot convert null URL literal to value of VariableDeclaration(TestVariable)')


def test_variable_of_string_to_literal_none(variable_of_string):
with pytest.raises(PyODataException) as e_info:
variable_of_string.to_literal(None)
assert str(e_info.value).startswith('Cannot convert None to URL literal of VariableDeclaration(TestVariable)')

0 comments on commit 7972f31

Please sign in to comment.