Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nullable values #100

Merged
merged 3 commits into from
Apr 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

### Changed
- handle GET EntitySet payload without the member results - Jakub Filak
- both Literal and JSON DateTimes has Timezone set to UTC - Jakub Filak

### Fixed
- 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
61 changes: 54 additions & 7 deletions pyodata/v2/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@
TypeInfo = collections.namedtuple('TypeInfo', 'namespace name is_collection')


def current_timezone():
"""Default Timezone for Python datetime instances when parsed from
Edm.DateTime values and vice versa.

OData V2 does not mention Timezones in the documentation of
Edm.DateTime and UTC was chosen because it is universal.
"""

return datetime.timezone.utc


def modlog():
return logging.getLogger(LOGGER_NAME)

Expand Down Expand Up @@ -276,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 @@ -290,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 @@ -304,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 @@ -387,7 +398,7 @@ def to_json(self, value):

# Converts datetime into timestamp in milliseconds in UTC timezone as defined in ODATA specification
# https://www.odata.org/documentation/odata-version-2-0/json-format/
return f'/Date({int(value.replace(tzinfo=datetime.timezone.utc).timestamp()) * 1000})/'
return f'/Date({int(value.replace(tzinfo=current_timezone()).timestamp()) * 1000})/'

def from_json(self, value):

Expand All @@ -402,7 +413,7 @@ def from_json(self, value):

try:
# https://stackoverflow.com/questions/36179914/timestamp-out-of-range-for-platform-localtime-gmtime-function
value = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(milliseconds=int(value))
value = datetime.datetime(1970, 1, 1, tzinfo=current_timezone()) + datetime.timedelta(milliseconds=int(value))
except ValueError:
raise PyODataModelError('Cannot decode datetime from value {}.'.format(value))

Expand All @@ -426,7 +437,7 @@ def from_literal(self, value):
except ValueError:
raise PyODataModelError('Cannot decode datetime from value {}.'.format(value))

return value
return value.replace(tzinfo=current_timezone())


class EdmStringTypTraits(TypTraits):
Expand Down Expand Up @@ -551,7 +562,7 @@ def from_json(self, value):
return EdmStructTypeSerializer.from_json(self._edm_type, value)

def from_literal(self, value):
return EdmStructTypeSerializer.from_json(self._edm_type, value)
return EdmStructTypeSerializer.from_literal(self._edm_type, value)


class EnumTypTrait(TypTraits):
Expand Down Expand Up @@ -700,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
12 changes: 10 additions & 2 deletions tests/test_model_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import pytest
from pyodata.v2.model import Schema, Typ, StructTypeProperty, Types, EntityType, EdmStructTypeSerializer, \
Association, AssociationSet, EndRole, AssociationSetEndRole, TypeInfo, MetadataBuilder, ParserError, PolicyWarning, \
PolicyIgnore, Config, PolicyFatal, NullType, NullAssociation
PolicyIgnore, Config, PolicyFatal, NullType, NullAssociation, current_timezone
from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError
from tests.conftest import assert_logging_policy

Expand Down Expand Up @@ -458,7 +458,7 @@ def test_traits_datetime():

# 1. direction Python -> OData

testdate = datetime(2005, 1, 28, 18, 30, 44, 123456, tzinfo=timezone.utc)
testdate = datetime(2005, 1, 28, 18, 30, 44, 123456, tzinfo=current_timezone())
assert typ.traits.to_literal(testdate) == "datetime'2005-01-28T18:30:44.123456'"

# without miliseconds part
Expand All @@ -481,19 +481,22 @@ def test_traits_datetime():
assert testdate.minute == 33
assert testdate.second == 6
assert testdate.microsecond == 654321
assert testdate.tzinfo == current_timezone()

# parsing without miliseconds
testdate = typ.traits.from_literal("datetime'1976-11-23T03:33:06'")
assert testdate.year == 1976
assert testdate.second == 6
assert testdate.microsecond == 0
assert testdate.tzinfo == current_timezone()

# parsing without seconds and miliseconds
testdate = typ.traits.from_literal("datetime'1976-11-23T03:33'")
assert testdate.year == 1976
assert testdate.minute == 33
assert testdate.second == 0
assert testdate.microsecond == 0
assert testdate.tzinfo == current_timezone()

# parsing invalid value
with pytest.raises(PyODataModelError) as e_info:
Expand All @@ -515,19 +518,22 @@ def test_traits_datetime():
assert testdate.minute == 33
assert testdate.second == 6
assert testdate.microsecond == 10000
assert testdate.tzinfo == current_timezone()

# parsing without miliseconds
testdate = typ.traits.from_json("/Date(217567986000)/")
assert testdate.year == 1976
assert testdate.second == 6
assert testdate.microsecond == 0
assert testdate.tzinfo == current_timezone()

# parsing without seconds and miliseconds
testdate = typ.traits.from_json("/Date(217567980000)/")
assert testdate.year == 1976
assert testdate.minute == 33
assert testdate.second == 0
assert testdate.microsecond == 0
assert testdate.tzinfo == current_timezone()

# parsing the lowest value
with pytest.raises(OverflowError):
Expand All @@ -541,6 +547,7 @@ def test_traits_datetime():
assert testdate.minute == 0
assert testdate.second == 0
assert testdate.microsecond == 0
assert testdate.tzinfo == current_timezone()

# parsing the highest value
with pytest.raises(OverflowError):
Expand All @@ -554,6 +561,7 @@ def test_traits_datetime():
assert testdate.minute == 59
assert testdate.second == 59
assert testdate.microsecond == 999000
assert testdate.tzinfo == current_timezone()

# parsing invalid value
with pytest.raises(PyODataModelError) as e_info:
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}'
Loading