Skip to content

Commit b4ce3ea

Browse files
bartonipfilak-sap
authored andcommitted
Implemented Django style filtering
Closes SAP#113 Signed-off-by: Jakub Filak <[email protected]>
1 parent 2d7cd16 commit b4ce3ea

File tree

6 files changed

+643
-6
lines changed

6 files changed

+643
-6
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1010
- Specify PATCH, PUT, or MERGE method for EntityUpdateRequest - Barton Ip
1111
- Add a Service wide configuration (e.g. http.update\_method) - Jakub Filak
1212
- <, <=, >, >= operators on GetEntitySetFilter - Barton Ip
13+
- Django style filtering - Barton Ip
1314

1415
### Fixed
1516
- URL encode $filter contents - Barton Ip

docs/usage/querying.rst

+30
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,36 @@ Print unique identification (Id) of all employees with name John Smith:
6666
print(smith.EmployeeID)
6767
6868
69+
Get entities matching a filter in ORM style
70+
---------------------------------------------------
71+
72+
Print unique identification (Id) of all employees with name John Smith:
73+
74+
.. code-block:: python
75+
76+
from pyodata.v2.service import GetEntitySetFilter as esf
77+
78+
smith_employees_request = northwind.entity_sets.Employees.get_entities()
79+
smith_employees_request = smith_employees_request.filter(FirstName="John", LastName="Smith")
80+
for smith in smith_employees_request.execute():
81+
print(smith.EmployeeID)
82+
83+
84+
Get entities matching a complex filter in ORM style
85+
---------------------------------------------------
86+
87+
Print unique identification (Id) of all employees with name John Smith:
88+
89+
.. code-block:: python
90+
91+
from pyodata.v2.service import GetEntitySetFilter as esf
92+
93+
smith_employees_request = northwind.entity_sets.Employees.get_entities()
94+
smith_employees_request = smith_employees_request.filter(FirstName__contains="oh", LastName__startswith="Smi")
95+
for smith in smith_employees_request.execute():
96+
print(smith.EmployeeID)
97+
98+
6999
Get a count of entities
70100
-----------------------
71101

pyodata/v2/model.py

+3
Original file line numberDiff line numberDiff line change
@@ -1287,6 +1287,9 @@ def proprty(self, property_name):
12871287
def proprties(self):
12881288
return list(self._properties.values())
12891289

1290+
def has_proprty(self, proprty_name):
1291+
return proprty_name in self._properties
1292+
12901293
@classmethod
12911294
def from_etree(cls, type_node, config: Config):
12921295
name = type_node.get('Name')

pyodata/v2/service.py

+222-2
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,9 @@ def execute(self):
314314
if body:
315315
self._logger.debug(' body: %s', body)
316316

317+
params = "&".join("%s=%s" % (k, v) for k, v in self.get_query_params().items())
317318
response = self._connection.request(
318-
self.get_method(), url, headers=headers, params=self.get_query_params(), data=body)
319+
self.get_method(), url, headers=headers, params=params, data=body)
319320

320321
self._logger.debug('Received response')
321322
self._logger.debug(' url: %s', response.url)
@@ -623,7 +624,7 @@ def expand(self, expand):
623624
def filter(self, filter_val):
624625
"""Sets the filter expression."""
625626
# returns QueryRequest
626-
self._filter = quote(filter_val)
627+
self._filter = filter_val
627628
return self
628629

629630
# def nav(self, key_value, nav_property):
@@ -993,6 +994,212 @@ def __gt__(self, value):
993994
return GetEntitySetFilter.format_filter(self._proprty, 'gt', value)
994995

995996

997+
class FilterExpression:
998+
"""A class representing named expression of OData $filter"""
999+
1000+
def __init__(self, **kwargs):
1001+
self._expressions = kwargs
1002+
self._other = None
1003+
self._operator = None
1004+
1005+
@property
1006+
def expressions(self):
1007+
"""Get expressions where key is property name with the operator suffix
1008+
and value is the left hand side operand.
1009+
"""
1010+
1011+
return self._expressions.items()
1012+
1013+
@property
1014+
def other(self):
1015+
"""Get an instance of the other operand"""
1016+
1017+
return self._other
1018+
1019+
@property
1020+
def operator(self):
1021+
"""The other operand"""
1022+
1023+
return self._operator
1024+
1025+
def __or__(self, other):
1026+
if self._other is not None:
1027+
raise RuntimeError('The FilterExpression already initialized')
1028+
1029+
self._other = other
1030+
self._operator = "or"
1031+
return self
1032+
1033+
def __and__(self, other):
1034+
if self._other is not None:
1035+
raise RuntimeError('The FilterExpression already initialized')
1036+
1037+
self._other = other
1038+
self._operator = "and"
1039+
return self
1040+
1041+
1042+
class GetEntitySetFilterChainable:
1043+
"""
1044+
Example expressions
1045+
FirstName='Tim'
1046+
FirstName__contains='Tim'
1047+
Age__gt=56
1048+
Age__gte=6
1049+
Age__lt=78
1050+
Age__lte=90
1051+
Age__range=(5,9)
1052+
FirstName__in=['Tim', 'Bob', 'Sam']
1053+
FirstName__startswith='Tim'
1054+
FirstName__endswith='mothy'
1055+
Addresses__Suburb='Chatswood'
1056+
Addresses__Suburb__contains='wood'
1057+
"""
1058+
1059+
OPERATORS = [
1060+
'startswith',
1061+
'endswith',
1062+
'lt',
1063+
'lte',
1064+
'gt',
1065+
'gte',
1066+
'contains',
1067+
'range',
1068+
'in',
1069+
'length',
1070+
'eq'
1071+
]
1072+
1073+
def __init__(self, entity_type, filter_expressions, exprs):
1074+
self._entity_type = entity_type
1075+
self._filter_expressions = filter_expressions
1076+
self._expressions = exprs
1077+
1078+
@property
1079+
def expressions(self):
1080+
"""Get expressions as a list of tuples where the first item
1081+
is a property name with the operator suffix and the second item
1082+
is a left hand side value.
1083+
"""
1084+
1085+
return self._expressions.items()
1086+
1087+
def proprty_obj(self, name):
1088+
"""Returns a model property for a particular property"""
1089+
1090+
return self._entity_type.proprty(name)
1091+
1092+
def _decode_and_combine_filter_expression(self, filter_expression):
1093+
filter_expressions = [self._decode_expression(expr, val) for expr, val in filter_expression.expressions]
1094+
return self._combine_expressions(filter_expressions)
1095+
1096+
def _process_query_objects(self):
1097+
"""Processes FilterExpression objects to OData lookups"""
1098+
1099+
filter_expressions = []
1100+
1101+
for expr in self._filter_expressions:
1102+
lhs_expressions = self._decode_and_combine_filter_expression(expr)
1103+
1104+
if expr.other is not None:
1105+
rhs_expressions = self._decode_and_combine_filter_expression(expr.other)
1106+
filter_expressions.append(f'({lhs_expressions}) {expr.operator} ({rhs_expressions})')
1107+
else:
1108+
filter_expressions.append(lhs_expressions)
1109+
1110+
return filter_expressions
1111+
1112+
def _process_expressions(self):
1113+
filter_expressions = [self._decode_expression(expr, val) for expr, val in self.expressions]
1114+
1115+
filter_expressions.extend(self._process_query_objects())
1116+
1117+
return filter_expressions
1118+
1119+
def _decode_expression(self, expr, val):
1120+
field = None
1121+
# field_heirarchy = []
1122+
operator = 'eq'
1123+
exprs = expr.split('__')
1124+
1125+
for part in exprs:
1126+
if self._entity_type.has_proprty(part):
1127+
field = part
1128+
# field_heirarchy.append(part)
1129+
elif part in self.__class__.OPERATORS:
1130+
operator = part
1131+
else:
1132+
raise ValueError(f'"{part}" is not a valid property or operator')
1133+
# field = '/'.join(field_heirarchy)
1134+
1135+
# target_field = self.proprty_obj(field_heirarchy[-1])
1136+
expression = self._build_expression(field, operator, val)
1137+
1138+
return expression
1139+
1140+
# pylint: disable=no-self-use
1141+
def _combine_expressions(self, expressions):
1142+
return ' and '.join(expressions)
1143+
1144+
# pylint: disable=too-many-return-statements, too-many-branches
1145+
def _build_expression(self, field_name, operator, value):
1146+
target_field = self.proprty_obj(field_name)
1147+
1148+
if operator not in ['length', 'in', 'range']:
1149+
value = target_field.to_literal(value)
1150+
1151+
if operator == 'lt':
1152+
return f'{field_name} lt {value}'
1153+
1154+
if operator == 'lte':
1155+
return f'{field_name} le {value}'
1156+
1157+
if operator == 'gte':
1158+
return f'{field_name} ge {value}'
1159+
1160+
if operator == 'gt':
1161+
return f'{field_name} gt {value}'
1162+
1163+
if operator == 'startswith':
1164+
return f'startswith({field_name}, {value}) eq true'
1165+
1166+
if operator == 'endswith':
1167+
return f'endswith({field_name}, {value}) eq true'
1168+
1169+
if operator == 'length':
1170+
value = int(value)
1171+
return f'length({field_name}) eq {value}'
1172+
1173+
if operator in ['contains']:
1174+
return f'substringof({value}, {field_name}) eq true'
1175+
1176+
if operator == 'range':
1177+
if not isinstance(value, (tuple, list)):
1178+
raise TypeError('Range must be tuple or list not {}'.format(type(value)))
1179+
1180+
if len(value) != 2:
1181+
raise ValueError('Only two items can be passed in a range.')
1182+
1183+
low_bound = target_field.to_literal(value[0])
1184+
high_bound = target_field.to_literal(value[1])
1185+
1186+
return f'{field_name} gte {low_bound} and {field_name} lte {high_bound}'
1187+
1188+
if operator == 'in':
1189+
literal_values = (f'{field_name} eq {target_field.to_literal(item)}' for item in value)
1190+
return ' or '.join(literal_values)
1191+
1192+
if operator == 'eq':
1193+
return f'{field_name} eq {value}'
1194+
1195+
raise ValueError(f'Invalid expression {operator}')
1196+
1197+
def __str__(self):
1198+
expressions = self._process_expressions()
1199+
result = self._combine_expressions(expressions)
1200+
return quote(result)
1201+
1202+
9961203
class GetEntitySetRequest(QueryRequest):
9971204
"""GET on EntitySet"""
9981205

@@ -1005,6 +1212,19 @@ def __getattr__(self, name):
10051212
proprty = self._entity_type.proprty(name)
10061213
return GetEntitySetFilter(proprty)
10071214

1215+
def _set_filter(self, filter_val):
1216+
filter_text = self._filter + ' and ' if self._filter else ''
1217+
filter_text += filter_val
1218+
self._filter = filter_text
1219+
1220+
def filter(self, *args, **kwargs):
1221+
if args and len(args) == 1 and isinstance(args[0], str):
1222+
self._filter = args[0]
1223+
else:
1224+
self._set_filter(str(GetEntitySetFilterChainable(self._entity_type, args, kwargs)))
1225+
1226+
return self
1227+
10081228

10091229
class EntitySetProxy:
10101230
"""EntitySet Proxy"""

tests/test_model_v2.py

+21-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pytest
77
from pyodata.v2.model import Schema, Typ, StructTypeProperty, Types, EntityType, EdmStructTypeSerializer, \
88
Association, AssociationSet, EndRole, AssociationSetEndRole, TypeInfo, MetadataBuilder, ParserError, PolicyWarning, \
9-
PolicyIgnore, Config, PolicyFatal, NullType, NullAssociation, current_timezone
9+
PolicyIgnore, Config, PolicyFatal, NullType, NullAssociation, current_timezone, StructType
1010
from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError
1111
from tests.conftest import assert_logging_policy
1212

@@ -1404,3 +1404,23 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac
14041404
)).build()
14051405

14061406
assert mock_warning.called is False
1407+
1408+
1409+
def test_struct_type_has_property_initial_instance():
1410+
struct_type = StructType('Name', 'Label', False)
1411+
1412+
assert struct_type.has_proprty('proprty') == False
1413+
1414+
1415+
def test_struct_type_has_property_no():
1416+
struct_type = StructType('Name', 'Label', False)
1417+
struct_type._properties['foo'] = 'ugly test hack'
1418+
1419+
assert not struct_type.has_proprty('proprty')
1420+
1421+
1422+
def test_struct_type_has_property_yes():
1423+
struct_type = StructType('Name', 'Label', False)
1424+
struct_type._properties['proprty'] = 'ugly test hack'
1425+
1426+
assert struct_type.has_proprty('proprty')

0 commit comments

Comments
 (0)