Skip to content

Commit 5ab9113

Browse files
committed
Add support for EntitySet in OData V4
1 parent c7f8f72 commit 5ab9113

8 files changed

Lines changed: 194 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1313
- Separate type repositories for individual versions of OData - Martin Miksik
1414
- Support for OData V4 primitive types - Martin Miksik
1515
- Support for navigation property in OData v4 - Martin Miksik
16+
- Support for EntitySet in OData v4 - Martin Miksik
1617

1718
### Changed
1819
- Implementation and naming schema of `from_etree` - Martin Miksik

pyodata/model/build_functions.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
Types, EntitySet, ValueHelper, ValueHelperParameter, FunctionImportParameter, \
1111
FunctionImport, metadata_attribute_get, EntityType, ComplexType, Annotation, build_element
1212

13+
from pyodata.v4 import ODataV4
14+
import pyodata.v4.elements as v4
15+
1316

1417
def modlog():
1518
return logging.getLogger("callbacks")
@@ -103,6 +106,10 @@ def build_entity_set(config, entity_set_node):
103106
name = entity_set_node.get('Name')
104107
et_info = Types.parse_type_name(entity_set_node.get('EntityType'))
105108

109+
nav_prop_bins = []
110+
for nav_prop_bin in entity_set_node.xpath('edm:NavigationPropertyBinding', namespaces=config.namespaces):
111+
nav_prop_bins.append(build_element('NavigationPropertyBinding', config, node=nav_prop_bin, et_info=et_info))
112+
106113
# TODO: create a class SAP attributes
107114
addressable = sap_attribute_get_bool(entity_set_node, 'addressable', True)
108115
creatable = sap_attribute_get_bool(entity_set_node, 'creatable', True)
@@ -115,6 +122,10 @@ def build_entity_set(config, entity_set_node):
115122
req_filter = sap_attribute_get_bool(entity_set_node, 'requires-filter', False)
116123
label = sap_attribute_get_string(entity_set_node, 'label')
117124

125+
if config.odata_version == ODataV4:
126+
return v4.EntitySet(name, et_info, addressable, creatable, updatable, deletable, searchable, countable, pageable,
127+
topable, req_filter, label, nav_prop_bins)
128+
118129
return EntitySet(name, et_info, addressable, creatable, updatable, deletable, searchable, countable, pageable,
119130
topable, req_filter, label)
120131

pyodata/model/elements.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,10 @@ def nav_proprties(self):
590590
return list(self._nav_properties.values())
591591

592592
def nav_proprty(self, property_name):
593-
return self._nav_properties[property_name]
593+
try:
594+
return self._nav_properties[property_name]
595+
except KeyError as ex:
596+
raise PyODataModelError(f'{self} does not contain navigation property {property_name}') from ex
594597

595598

596599
class EntitySet(Identifier):

pyodata/policies.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ class ParserError(Enum):
1212
""" Represents all the different errors the parser is able to deal with."""
1313
PROPERTY = auto()
1414
NAVIGATION_PROPERTY = auto()
15+
NAVIGATION_PROPERTY_BIDING = auto()
1516
ANNOTATION = auto()
1617
ASSOCIATION = auto()
1718

1819
ENUM_TYPE = auto()
1920
ENTITY_TYPE = auto()
21+
ENTITY_SET = auto()
2022
COMPLEX_TYPE = auto()
2123
REFERENTIAL_CONSTRAINT = auto()
2224

pyodata/v4/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pyodata.model.type_traits import EdmBooleanTypTraits, EdmIntTypTraits
77
from pyodata.model.elements import Typ, Schema, ComplexType, StructType, StructTypeProperty, EntityType
88

9-
from pyodata.v4.elements import NavigationTypeProperty, EnumType
9+
from pyodata.v4.elements import NavigationTypeProperty, NavigationPropertyBinding, EntitySet, EnumType
1010
from pyodata.v4.type_traits import EdmDateTypTraits, GeoTypeTraits, EdmDoubleQuotesEncapsulatedTypTraits, \
1111
EdmTimeOfDay, EdmDateTimeOffsetTypTraits, EdmDuration
1212

@@ -23,9 +23,11 @@ def build_functions():
2323
StructTypeProperty: build_functions.build_struct_type_property,
2424
StructType: build_functions.build_struct_type,
2525
NavigationTypeProperty: build_functions_v4.build_navigation_type_property,
26+
NavigationPropertyBinding: build_functions_v4.build_navigation_property_binding,
2627
EnumType: build_functions_v4.build_enum_type,
2728
ComplexType: build_functions.build_complex_type,
2829
EntityType: build_functions.build_entity_type,
30+
EntitySet: build_functions.build_entity_set,
2931
Schema: build_functions_v4.build_schema,
3032
}
3133

pyodata/v4/build_functions.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from pyodata.exceptions import PyODataParserError, PyODataModelError
88
from pyodata.model.elements import ComplexType, Schema, NullType, build_element, EntityType, Types, StructTypeProperty
99
from pyodata.policies import ParserError
10-
from pyodata.v4.elements import NavigationTypeProperty, NullProperty, ReferentialConstraint, EnumMember, EnumType
10+
from pyodata.v4.elements import NavigationTypeProperty, NullProperty, ReferentialConstraint,\
11+
NavigationPropertyBinding, to_path_info, EntitySet, EnumMember, EnumType
1112

1213

1314
# pylint: disable=protected-access,too-many-locals,too-many-branches,too-many-statements
@@ -110,10 +111,33 @@ def build_schema(config: Config, schema_nodes):
110111
ref_con.proprty = proprty
111112
ref_con.referenced_proprty = referenced_proprty
112113

113-
# TODO: Then, process Associations nodes because they refer EntityTypes and they are referenced by AssociationSets.
114-
# TODO: Then, process EntitySet, FunctionImport and AssociationSet nodes.
115-
# TODO: Finally, process Annotation nodes when all Scheme nodes are completely processed.
114+
# Process entity sets
115+
for schema_node in schema_nodes:
116+
namespace = schema_node.get('Namespace')
117+
decl = schema._decls[namespace]
116118

119+
for entity_set in schema_node.xpath('edm:EntityContainer/edm:EntitySet', namespaces=config.namespaces):
120+
try:
121+
eset = build_element(EntitySet, config, entity_set_node=entity_set)
122+
eset.entity_type = schema.entity_type(eset.entity_type_info[1], namespace=eset.entity_type_info[0])
123+
decl.entity_sets[eset.name] = eset
124+
except (PyODataParserError, KeyError) as ex:
125+
config.err_policy(ParserError.ENTITY_SET).resolve(ex)
126+
127+
# After all entity sets are parsed resolve the individual bindings among them and entity types
128+
for entity_set in schema.entity_sets:
129+
for nav_prop_bin in entity_set.navigation_property_bindings:
130+
path_info = nav_prop_bin.path_info
131+
try:
132+
nav_prop_bin.path = schema.entity_type(path_info.type,
133+
namespace=path_info.namespace).nav_proprty(path_info.proprty)
134+
nav_prop_bin.target = schema.entity_set(nav_prop_bin.target_info)
135+
except (PyODataModelError, KeyError) as ex:
136+
config.err_policy(ParserError.NAVIGATION_PROPERTY_BIDING).resolve(ex)
137+
nav_prop_bin.path = NullType(path_info.type)
138+
nav_prop_bin.target = NullProperty(nav_prop_bin.target_info)
139+
140+
# TODO: Finally, process Annotation nodes when all Scheme nodes are completely processed.
117141
return schema
118142

119143

@@ -133,6 +157,10 @@ def build_navigation_type_property(config: Config, node):
133157
ref_cons)
134158

135159

160+
def build_navigation_property_binding(config: Config, node, et_info):
161+
return NavigationPropertyBinding(to_path_info(node.get('Path'), et_info), node.get('Target'))
162+
163+
136164
# pylint: disable=protected-access, too-many-locals
137165
def build_enum_type(config: Config, type_node, namespace):
138166
ename = type_node.get('Name')

pyodata/v4/elements.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
11
""" Repository of elements specific to the ODATA V4"""
22
from typing import Optional, List
33

4+
import collections
5+
6+
from pyodata.model import elements
47
from pyodata.exceptions import PyODataModelError, PyODataException
5-
from pyodata.model.elements import VariableDeclaration, StructType, Identifier
8+
from pyodata.model.elements import VariableDeclaration, StructType, TypeInfo, Identifier
69
from pyodata.model.type_traits import TypTraits
710
from pyodata.v4.type_traits import EnumTypTrait
811

12+
PathInfo = collections.namedtuple('PathInfo', 'namespace type proprty')
13+
14+
15+
def to_path_info(value: str, et_info: TypeInfo):
16+
""" Helper function for parsing Path attribute on NavigationPropertyBinding property """
17+
if '/' in value:
18+
parts = value.split('.')
19+
entity_name, property_name = parts[-1].split('/')
20+
return PathInfo('.'.join(parts[:-1]), entity_name, property_name)
21+
else:
22+
return PathInfo(et_info.namespace, et_info.name, value)
23+
924

1025
class NullProperty:
1126
""" Defines fallback class when parser is unable to process property defined in xml """
@@ -94,6 +109,64 @@ def referential_constraints(self) -> List[ReferentialConstraint]:
94109
return self._referential_constraints
95110

96111

112+
class NavigationPropertyBinding:
113+
""" Describes which entity set of navigation property contains related entities
114+
https://docs.oasis-open.org/odata/odata-csdl-xml/v4.01/csprd06/odata-csdl-xml-v4.01-csprd06.html#sec_NavigationPropertyBinding
115+
"""
116+
117+
def __init__(self, path_info: PathInfo, target_info: str):
118+
self._path_info = path_info
119+
self._target_info = target_info
120+
self._path: Optional[NavigationTypeProperty] = None
121+
self._target: Optional['EntitySet'] = None
122+
123+
def __repr__(self):
124+
return f"{self.__class__.__name__}({self.path}, {self.target})"
125+
126+
def __str__(self):
127+
return f"{self.__class__.__name__}({self.path}, {self.target})"
128+
129+
@property
130+
def path_info(self) -> PathInfo:
131+
return self._path_info
132+
133+
@property
134+
def target_info(self):
135+
return self._target_info
136+
137+
@property
138+
def path(self) -> Optional[NavigationTypeProperty]:
139+
return self._path
140+
141+
@path.setter
142+
def path(self, value: NavigationTypeProperty):
143+
self._path = value
144+
145+
@property
146+
def target(self) -> Optional['EntitySet']:
147+
return self._target
148+
149+
@target.setter
150+
def target(self, value: 'EntitySet'):
151+
self._target = value
152+
153+
154+
class EntitySet(elements.EntitySet):
155+
""" EntitySet complaint with OData V4
156+
https://docs.oasis-open.org/odata/odata-csdl-xml/v4.01/csprd06/odata-csdl-xml-v4.01-csprd06.html#sec_EntitySet
157+
"""
158+
def __init__(self, name, entity_type_info, addressable, creatable, updatable, deletable, searchable, countable,
159+
pageable, topable, req_filter, label, navigation_property_bindings):
160+
super(EntitySet, self).__init__(name, entity_type_info, addressable, creatable, updatable, deletable,
161+
searchable, countable, pageable, topable, req_filter, label)
162+
163+
self._navigation_property_bindings = navigation_property_bindings
164+
165+
@property
166+
def navigation_property_bindings(self) -> List[NavigationPropertyBinding]:
167+
return self._navigation_property_bindings
168+
169+
97170
class EnumMember:
98171
""" Represents individual enum values """
99172
def __init__(self, parent, name, value):

tests/test_model_v4.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import geojson
44
import pytest
55

6-
from pyodata.policies import PolicyIgnore
6+
from pyodata.policies import PolicyIgnore, ParserError
77
from pyodata.model.builder import MetadataBuilder
88
from pyodata.exceptions import PyODataModelError, PyODataException, PyODataParserError
9-
from pyodata.model.elements import Types, TypeInfo, NullType
9+
from pyodata.model.elements import Types, TypeInfo, Schema, NullType
1010

1111
from pyodata.config import Config
12+
from tests.conftest import metadata
13+
from pyodata.v4.elements import NavigationTypeProperty, EntitySet, NavigationPropertyBinding
1214
from pyodata.v4 import ODataV4, NavigationTypeProperty
1315

1416

@@ -171,6 +173,69 @@ def test_referential_constraint(schema_v4):
171173
'ReferentialConstraint(StructTypeProperty(CategoryID), StructTypeProperty(ID))'
172174

173175

176+
def test_navigation_property_binding(schema_v4: Schema):
177+
"""Test parsing of navigation property bindings on EntitySets"""
178+
eset: EntitySet = schema_v4.entity_set('People')
179+
assert str(eset) == 'EntitySet(People)'
180+
181+
nav_prop_biding: NavigationPropertyBinding = eset.navigation_property_bindings[0]
182+
assert repr(nav_prop_biding) == "NavigationPropertyBinding(NavigationTypeProperty(Friends), EntitySet(People))"
183+
184+
185+
def test_invalid_property_binding_on_entity_set(xml_builder_factory):
186+
"""Test parsing of invalid property bindings on EntitySets"""
187+
schema = """
188+
<EntityType Name="Person">
189+
<NavigationProperty Name="Friends" Type="Collection(MightySchema.Person)" Partner="Friends" />
190+
</EntityType>
191+
<EntityContainer Name="DefaultContainer">
192+
<EntitySet Name="People" EntityType="{}">
193+
<NavigationPropertyBinding Path="{}" Target="{}" />
194+
</EntitySet>
195+
</EntityContainer>
196+
"""
197+
198+
etype, path, target = 'MightySchema.Person', 'Friends', 'People'
199+
200+
xml_builder = xml_builder_factory()
201+
xml_builder.add_schema('MightySchema', schema.format(etype, 'Mistake', target))
202+
xml = xml_builder.serialize()
203+
204+
with pytest.raises(PyODataModelError) as ex_info:
205+
MetadataBuilder(xml, Config(ODataV4)).build()
206+
assert ex_info.value.args[0] == 'EntityType(Person) does not contain navigation property Mistake'
207+
208+
try:
209+
MetadataBuilder(xml, Config(ODataV4, custom_error_policies={
210+
ParserError.NAVIGATION_PROPERTY_BIDING: PolicyIgnore()
211+
})).build()
212+
except BaseException as ex:
213+
raise pytest.fail(f'IgnorePolicy was supposed to silence "{ex}" but it did not.')
214+
215+
xml_builder = xml_builder_factory()
216+
xml_builder.add_schema('MightySchema', schema.format('Mistake', path, target))
217+
xml = xml_builder.serialize()
218+
219+
with pytest.raises(KeyError) as ex_info:
220+
MetadataBuilder(xml, Config(ODataV4)).build()
221+
assert ex_info.value.args[0] == 'EntityType Mistake does not exist in any Schema Namespace'
222+
223+
try:
224+
MetadataBuilder(xml, Config(ODataV4, custom_error_policies={
225+
ParserError.ENTITY_SET: PolicyIgnore()
226+
})).build()
227+
except BaseException as ex:
228+
raise pytest.fail(f'IgnorePolicy was supposed to silence "{ex}" but it did not.')
229+
230+
xml_builder = xml_builder_factory()
231+
xml_builder.add_schema('MightySchema', schema.format(etype, path, 'Mistake'))
232+
xml = xml_builder.serialize()
233+
234+
with pytest.raises(KeyError) as ex_info:
235+
MetadataBuilder(xml, Config(ODataV4)).build()
236+
assert ex_info.value.args[0] == 'EntitySet Mistake does not exist in any Schema Namespace'
237+
238+
174239
def test_enum_parsing(schema_v4):
175240
"""Test correct parsing of enum"""
176241

0 commit comments

Comments
 (0)