Skip to content

Commit

Permalink
Implemented Django style filtering
Browse files Browse the repository at this point in the history
Closes #113

Signed-off-by: Jakub Filak <[email protected]>
  • Loading branch information
bartonip authored and filak-sap committed Jun 29, 2020
1 parent 2d7cd16 commit b4ce3ea
Show file tree
Hide file tree
Showing 6 changed files with 643 additions and 6 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Specify PATCH, PUT, or MERGE method for EntityUpdateRequest - Barton Ip
- Add a Service wide configuration (e.g. http.update\_method) - Jakub Filak
- <, <=, >, >= operators on GetEntitySetFilter - Barton Ip
- Django style filtering - Barton Ip

### Fixed
- URL encode $filter contents - Barton Ip
Expand Down
30 changes: 30 additions & 0 deletions docs/usage/querying.rst
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,36 @@ Print unique identification (Id) of all employees with name John Smith:
print(smith.EmployeeID)
Get entities matching a filter in ORM style
---------------------------------------------------

Print unique identification (Id) of all employees with name John Smith:

.. code-block:: python
from pyodata.v2.service import GetEntitySetFilter as esf
smith_employees_request = northwind.entity_sets.Employees.get_entities()
smith_employees_request = smith_employees_request.filter(FirstName="John", LastName="Smith")
for smith in smith_employees_request.execute():
print(smith.EmployeeID)
Get entities matching a complex filter in ORM style
---------------------------------------------------

Print unique identification (Id) of all employees with name John Smith:

.. code-block:: python
from pyodata.v2.service import GetEntitySetFilter as esf
smith_employees_request = northwind.entity_sets.Employees.get_entities()
smith_employees_request = smith_employees_request.filter(FirstName__contains="oh", LastName__startswith="Smi")
for smith in smith_employees_request.execute():
print(smith.EmployeeID)
Get a count of entities
-----------------------

Expand Down
3 changes: 3 additions & 0 deletions pyodata/v2/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1287,6 +1287,9 @@ def proprty(self, property_name):
def proprties(self):
return list(self._properties.values())

def has_proprty(self, proprty_name):
return proprty_name in self._properties

@classmethod
def from_etree(cls, type_node, config: Config):
name = type_node.get('Name')
Expand Down
224 changes: 222 additions & 2 deletions pyodata/v2/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,9 @@ def execute(self):
if body:
self._logger.debug(' body: %s', body)

params = "&".join("%s=%s" % (k, v) for k, v in self.get_query_params().items())
response = self._connection.request(
self.get_method(), url, headers=headers, params=self.get_query_params(), data=body)
self.get_method(), url, headers=headers, params=params, data=body)

self._logger.debug('Received response')
self._logger.debug(' url: %s', response.url)
Expand Down Expand Up @@ -623,7 +624,7 @@ def expand(self, expand):
def filter(self, filter_val):
"""Sets the filter expression."""
# returns QueryRequest
self._filter = quote(filter_val)
self._filter = filter_val
return self

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


class FilterExpression:
"""A class representing named expression of OData $filter"""

def __init__(self, **kwargs):
self._expressions = kwargs
self._other = None
self._operator = None

@property
def expressions(self):
"""Get expressions where key is property name with the operator suffix
and value is the left hand side operand.
"""

return self._expressions.items()

@property
def other(self):
"""Get an instance of the other operand"""

return self._other

@property
def operator(self):
"""The other operand"""

return self._operator

def __or__(self, other):
if self._other is not None:
raise RuntimeError('The FilterExpression already initialized')

self._other = other
self._operator = "or"
return self

def __and__(self, other):
if self._other is not None:
raise RuntimeError('The FilterExpression already initialized')

self._other = other
self._operator = "and"
return self


class GetEntitySetFilterChainable:
"""
Example expressions
FirstName='Tim'
FirstName__contains='Tim'
Age__gt=56
Age__gte=6
Age__lt=78
Age__lte=90
Age__range=(5,9)
FirstName__in=['Tim', 'Bob', 'Sam']
FirstName__startswith='Tim'
FirstName__endswith='mothy'
Addresses__Suburb='Chatswood'
Addresses__Suburb__contains='wood'
"""

OPERATORS = [
'startswith',
'endswith',
'lt',
'lte',
'gt',
'gte',
'contains',
'range',
'in',
'length',
'eq'
]

def __init__(self, entity_type, filter_expressions, exprs):
self._entity_type = entity_type
self._filter_expressions = filter_expressions
self._expressions = exprs

@property
def expressions(self):
"""Get expressions as a list of tuples where the first item
is a property name with the operator suffix and the second item
is a left hand side value.
"""

return self._expressions.items()

def proprty_obj(self, name):
"""Returns a model property for a particular property"""

return self._entity_type.proprty(name)

def _decode_and_combine_filter_expression(self, filter_expression):
filter_expressions = [self._decode_expression(expr, val) for expr, val in filter_expression.expressions]
return self._combine_expressions(filter_expressions)

def _process_query_objects(self):
"""Processes FilterExpression objects to OData lookups"""

filter_expressions = []

for expr in self._filter_expressions:
lhs_expressions = self._decode_and_combine_filter_expression(expr)

if expr.other is not None:
rhs_expressions = self._decode_and_combine_filter_expression(expr.other)
filter_expressions.append(f'({lhs_expressions}) {expr.operator} ({rhs_expressions})')
else:
filter_expressions.append(lhs_expressions)

return filter_expressions

def _process_expressions(self):
filter_expressions = [self._decode_expression(expr, val) for expr, val in self.expressions]

filter_expressions.extend(self._process_query_objects())

return filter_expressions

def _decode_expression(self, expr, val):
field = None
# field_heirarchy = []
operator = 'eq'
exprs = expr.split('__')

for part in exprs:
if self._entity_type.has_proprty(part):
field = part
# field_heirarchy.append(part)
elif part in self.__class__.OPERATORS:
operator = part
else:
raise ValueError(f'"{part}" is not a valid property or operator')
# field = '/'.join(field_heirarchy)

# target_field = self.proprty_obj(field_heirarchy[-1])
expression = self._build_expression(field, operator, val)

return expression

# pylint: disable=no-self-use
def _combine_expressions(self, expressions):
return ' and '.join(expressions)

# pylint: disable=too-many-return-statements, too-many-branches
def _build_expression(self, field_name, operator, value):
target_field = self.proprty_obj(field_name)

if operator not in ['length', 'in', 'range']:
value = target_field.to_literal(value)

if operator == 'lt':
return f'{field_name} lt {value}'

if operator == 'lte':
return f'{field_name} le {value}'

if operator == 'gte':
return f'{field_name} ge {value}'

if operator == 'gt':
return f'{field_name} gt {value}'

if operator == 'startswith':
return f'startswith({field_name}, {value}) eq true'

if operator == 'endswith':
return f'endswith({field_name}, {value}) eq true'

if operator == 'length':
value = int(value)
return f'length({field_name}) eq {value}'

if operator in ['contains']:
return f'substringof({value}, {field_name}) eq true'

if operator == 'range':
if not isinstance(value, (tuple, list)):
raise TypeError('Range must be tuple or list not {}'.format(type(value)))

if len(value) != 2:
raise ValueError('Only two items can be passed in a range.')

low_bound = target_field.to_literal(value[0])
high_bound = target_field.to_literal(value[1])

return f'{field_name} gte {low_bound} and {field_name} lte {high_bound}'

if operator == 'in':
literal_values = (f'{field_name} eq {target_field.to_literal(item)}' for item in value)
return ' or '.join(literal_values)

if operator == 'eq':
return f'{field_name} eq {value}'

raise ValueError(f'Invalid expression {operator}')

def __str__(self):
expressions = self._process_expressions()
result = self._combine_expressions(expressions)
return quote(result)


class GetEntitySetRequest(QueryRequest):
"""GET on EntitySet"""

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

def _set_filter(self, filter_val):
filter_text = self._filter + ' and ' if self._filter else ''
filter_text += filter_val
self._filter = filter_text

def filter(self, *args, **kwargs):
if args and len(args) == 1 and isinstance(args[0], str):
self._filter = args[0]
else:
self._set_filter(str(GetEntitySetFilterChainable(self._entity_type, args, kwargs)))

return self


class EntitySetProxy:
"""EntitySet Proxy"""
Expand Down
22 changes: 21 additions & 1 deletion 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, current_timezone
PolicyIgnore, Config, PolicyFatal, NullType, NullAssociation, current_timezone, StructType
from pyodata.exceptions import PyODataException, PyODataModelError, PyODataParserError
from tests.conftest import assert_logging_policy

Expand Down Expand Up @@ -1404,3 +1404,23 @@ def test_missing_property_referenced_in_annotation(mock_warning, xml_builder_fac
)).build()

assert mock_warning.called is False


def test_struct_type_has_property_initial_instance():
struct_type = StructType('Name', 'Label', False)

assert struct_type.has_proprty('proprty') == False


def test_struct_type_has_property_no():
struct_type = StructType('Name', 'Label', False)
struct_type._properties['foo'] = 'ugly test hack'

assert not struct_type.has_proprty('proprty')


def test_struct_type_has_property_yes():
struct_type = StructType('Name', 'Label', False)
struct_type._properties['proprty'] = 'ugly test hack'

assert struct_type.has_proprty('proprty')
Loading

0 comments on commit b4ce3ea

Please sign in to comment.