Skip to content

Commit d76e042

Browse files
authored
Feature/query encoding (#27)
* Adding class _QueryUtil to bundle query encoding related utilities. * Adding tests for special character encoding. * Fixed handling and documentation of inventory API for querying by name. Added query parameter for specification of custom queries.
1 parent 14339fb commit d76e042

File tree

6 files changed

+176
-34
lines changed

6 files changed

+176
-34
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33

44
## Work in progress
55

6+
* Adding class _QueryUtil, bundling query encoding related functionality.
7+
8+
* Added tests for special character parsing.
9+
10+
* Fixed handling and documentation of inventory API for querying by name.
11+
Added query parameter for specification of custom queries.
12+
613
* Reverted changes in ComplexObject - a ComplexObject is not a dictionary-like class, it only
714
supports some dictionary-like access functions. But, for instance, updating a ComplexObject
815
is very different from updating a dictionary. Hence, it no longer inherits MutableMapping.

c8y_api/model/_util.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@
66

77
from datetime import datetime, timedelta, timezone
88
from dateutil import parser
9+
from re import sub
10+
11+
12+
class _QueryUtil(object):
13+
14+
@staticmethod
15+
def encode_odata_query_value(value):
16+
"""Encode value strings according to OData query rules.
17+
http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_URLParsing
18+
http://docs.oasis-open.org/odata/odata/v4.01/cs01/abnf/odata-abnf-construction-rules.txt """
19+
# single quotes escaped through single quote
20+
return sub('\'', '\'\'', value)
921

1022

1123
class _DateUtil(object):

c8y_api/model/inventory.py

Lines changed: 83 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import Any, Generator, List
1111

1212
from c8y_api.model._base import CumulocityResource
13+
from c8y_api.model._util import _QueryUtil
1314
from c8y_api.model.managedobjects import ManagedObjectUtil, ManagedObject, Device, DeviceGroup
1415

1516

@@ -52,10 +53,10 @@ def get_all(self, type: str = None, fragment: str = None, name: str = None, owne
5253
Returns:
5354
List of ManagedObject instances
5455
"""
55-
return list(self.select(type=type, fragment=fragment, name=name, limit=limit, page_size=page_size))
56+
return list(self.select(type=type, fragment=fragment, name=name, owner=owner, limit=limit, page_size=page_size))
5657

5758
def select(self, type: str = None, fragment: str = None, name: str = None, owner: str = None, # noqa (type)
58-
limit: int = None, page_size: int = 1000) -> Generator[ManagedObject]:
59+
query: str = None, limit: int = None, page_size: int = 1000) -> Generator[ManagedObject]:
5960
""" Query the database for managed objects and iterate over the
6061
results.
6162
@@ -70,7 +71,12 @@ def select(self, type: str = None, fragment: str = None, name: str = None, owner
7071
type (str): Managed object type
7172
fragment (str): Name of a present custom/standard fragment
7273
name (str): Name of the managed object
74+
Note: The Cumulocity REST API does not support filtering for
75+
names directly; this is a convenience parameter which will
76+
translate all filters into a query string.
7377
owner (str): Username of the object owner
78+
query (str): Complex query to execute; all other filters are
79+
ignored if such a custom query is provided
7480
limit (int): Limit the number of results to this number.
7581
page_size (int): Define the number of events which are read (and
7682
parsed in one chunk). This is a performance related setting.
@@ -79,12 +85,37 @@ def select(self, type: str = None, fragment: str = None, name: str = None, owner
7985
Generator for ManagedObject instances
8086
"""
8187
return self._select(ManagedObject.from_json, type=type, fragment=fragment, name=name, owner=owner,
82-
limit=limit, page_size=page_size)
88+
query=None, limit=limit, page_size=page_size)
89+
90+
def _select(self, jsonyfy_func, type: str = None, fragment: str = None, name: str = None, # noqa
91+
owner: str = None, query: str = None, limit: int = None, page_size: int = 1000) -> Generator[Any]:
92+
93+
query_filters = []
94+
95+
# if there is no custom query, we check whether standard filters need to
96+
# be translated into a query
97+
if not query and name:
98+
99+
# A name filter can only be expressed as a query, which then
100+
# triggers "query mode" (all filters are translated into a query)
101+
query_filters.append(f"name eq '{_QueryUtil.encode_odata_query_value(name)}'")
102+
103+
if type:
104+
query_filters.append(f"type eq '{type}'")
105+
if owner:
106+
query_filters.append(f"owner eq '{owner}'")
107+
if fragment:
108+
query_filters.append(f"has({fragment})")
109+
if len(query_filters) == 1:
110+
query = query_filters[0]
111+
else:
112+
query = '$filter=(' + ' and '.join(query_filters) + ')'
113+
114+
if query:
115+
base_query = self._build_base_query(query=query, page_size=page_size)
116+
else:
117+
base_query = self._build_base_query(type=type, fragment=fragment, owner=owner, page_size=page_size)
83118

84-
def _select(self, jsonyfy_func, type: str = None, fragment: str = None, name: str = None,
85-
owner: str = None, limit: int = None, page_size: int = 1000) -> Generator[Any]:
86-
base_query = self._build_base_query(type=type, fragment=fragment, owner=owner,
87-
query=f"name eq '{name}'" if name else None, page_size=page_size)
88119
return super()._iterate(base_query, limit, jsonyfy_func)
89120

90121
def create(self, *objects: ManagedObject):
@@ -166,7 +197,7 @@ def get(self, id: str) -> Device: # noqa (id)
166197
return device
167198

168199
def select(self, type: str = None, name: str = None, owner: str = None, # noqa (type, args)
169-
limit: int = None, page_size: int = 100) -> Generator[Device]:
200+
query: str = None, limit: int = None, page_size: int = 100) -> Generator[Device]:
170201
# pylint: disable=arguments-differ
171202
""" Query the database for devices and iterate over the results.
172203
@@ -180,7 +211,12 @@ def select(self, type: str = None, name: str = None, owner: str = None, # noqa
180211
Args:
181212
type (str): Device type
182213
name (str): Name of the device
214+
Note: The Cumulocity REST API does not support filtering for
215+
names directly; this is a convenience parameter which will
216+
translate all filters into a query string.
183217
owner (str): Username of the object owner
218+
query (str): Complex query to execute; all other filters are
219+
ignored if such a custom query is provided
184220
limit (int): Limit the number of results to this number.
185221
page_size (int): Define the number of events which are read (and
186222
parsed in one chunk). This is a performance related setting.
@@ -189,7 +225,7 @@ def select(self, type: str = None, name: str = None, owner: str = None, # noqa
189225
Generator for Device objects
190226
"""
191227
return self._select(ManagedObject.from_json, type=type, fragment='c8y_IsDevice', name=name, owner=owner,
192-
limit=limit, page_size=page_size)
228+
query=query, limit=limit, page_size=page_size)
193229

194230
def get_all(self, type: str = None, name: str = None, owner: str = None, # noqa (type, parameters)
195231
page_size: int = 100) -> List[Device]:
@@ -244,8 +280,8 @@ def get(self, group_id):
244280
group.c8y = self.c8y
245281
return group
246282

247-
def select(self, type: str = DeviceGroup.ROOT_TYPE, parent: str | int = None, fragment: str = None,
248-
name: str = None, owner: str = None, page_size: int = 100) -> Generator[DeviceGroup]:
283+
def select(self, type: str = DeviceGroup.ROOT_TYPE, parent: str | int = None, fragment: str = None, # noqa
284+
name: str = None, owner: str = None, query: str = None, page_size: int = 100) -> Generator[DeviceGroup]:
249285
# pylint: disable=arguments-differ, arguments-renamed
250286
""" Select device groups by various parameters.
251287
@@ -263,41 +299,55 @@ def select(self, type: str = DeviceGroup.ROOT_TYPE, parent: str | int = None, fr
263299
match device groups only you need to use the fragment filter.
264300
parent (str): ID of the parent device group
265301
Note: this forces the `type` filter to be c8y_DeviceSubGroup
302+
Like the `name` parameter, this is a convenience parameter
303+
which will translate all filters into a query string.
266304
fragment (str): Additional fragment present within the objects
267-
name (str): Name string of the groups to select; no partial
268-
matching/patterns are supported
305+
name (str): Name string of the groups to select
306+
Note: he Cumulocity REST API does not support filtering for
307+
names directly; this is a convenience parameter which will
308+
translate all filters into a query string.
309+
No partial matching/patterns are supported
269310
owner (str): Username of the group owner
311+
query (str): Complex query to execute; all other filters are
312+
ignored if such a custom query is provided
270313
page_size (int): Define the number of events which are read (and
271314
parsed in one chunk). This is a performance related setting.
272315
273316
Returns:
274317
Generator of DeviceGroup instances
275318
"""
276319
query_filters = []
277-
if name:
278-
query_filters.append(f"name eq '{name}'")
279-
if parent:
280-
query_filters.append(f"bygroupid({parent})")
281-
type = DeviceGroup.CHILD_TYPE
282-
283-
# if any query was defined, all filters must be put into the query
284-
if query_filters:
285-
query_filters.append(f"type eq {type}")
286-
# all other filters must be set as well
287-
if fragment:
288-
query_filters.append(f"has({fragment})")
289-
if owner:
290-
query_filters.append(f"owner eq '{owner}'")
291-
query = '$filter=' + ' and '.join(query_filters)
292320

321+
# if there is no custom query, we check whether standard filters need to
322+
# be translated into a query
323+
if not query:
324+
325+
# Both name and parent filters can only be expressed as a query,
326+
# which then triggers "query mode"
327+
if name:
328+
query_filters.append(f"name eq '{_QueryUtil.encode_odata_query_value(name)}'")
329+
if parent:
330+
query_filters.append(f"bygroupid({parent})")
331+
type = DeviceGroup.CHILD_TYPE # noqa
332+
333+
# if any query was defined, all filters must be put into the query
334+
if query_filters:
335+
if type:
336+
query_filters.append(f"type eq '{type}'")
337+
if owner:
338+
query_filters.append(f"owner eq '{owner}'")
339+
if fragment:
340+
query_filters.append(f"has({fragment}")
341+
query = '$filter=' + ' and '.join(query_filters)
342+
343+
if query:
293344
base_query = self._build_base_query(query=query, page_size=page_size)
294-
# otherwise we can just build the regular query
295345
else:
296346
base_query = self._build_base_query(type=type, fragment=fragment, owner=owner, page_size=page_size)
297347

298348
return super()._iterate(base_query, limit=9999, parse_func=DeviceGroup.from_json)
299349

300-
def get_all(self, type: str = DeviceGroup.ROOT_TYPE, parent: str | int = None, fragment: str = None,
350+
def get_all(self, type: str = DeviceGroup.ROOT_TYPE, parent: str | int = None, fragment: str = None, # noqa
301351
name: str = None, owner: str = None, page_size: int = 100): # noqa
302352
# pylint: disable=arguments-differ, arguments-renamed
303353
""" Select managed objects by various parameters.
@@ -328,9 +378,9 @@ def assign_children(self, root_id, *child_ids):
328378
# adding multiple references at once is not (yet) supported
329379
# refs = {'references': [InventoryUtil.build_managed_object_reference(id) for id in child_ids]}
330380
# self.c8y.post(self.build_object_path(root_id) + '/childAssets', json=refs, accept='')
331-
for id in child_ids:
381+
for child_id in child_ids:
332382
self.c8y.post(self.build_object_path(root_id) + '/childAssets',
333-
json=ManagedObjectUtil.build_managed_object_reference(id), accept='')
383+
json=ManagedObjectUtil.build_managed_object_reference(child_id), accept='')
334384

335385
def unassign_children(self, root_id, *child_ids):
336386
"""Unlink child groups from this device group.
@@ -339,7 +389,7 @@ def unassign_children(self, root_id, *child_ids):
339389
root_id (str|int): ID of the root device group
340390
child_ids (*str|int): ID of the child device groups
341391
"""
342-
refs = {'references': [ManagedObjectUtil.build_managed_object_reference(id) for id in child_ids]}
392+
refs = {'references': [ManagedObjectUtil.build_managed_object_reference(i) for i in child_ids]}
343393
self.c8y.delete(self.build_object_path(root_id) + '/childAssets', json=refs)
344394

345395
def delete(self, *groups: DeviceGroup | str | int):

integration_tests/test_inventory.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def similar_objects(object_factory) -> List[ManagedObject]:
8787

8888
@pytest.mark.parametrize('key, value_fun', [
8989
('type', lambda mo: mo.type),
90+
('name', lambda mo: mo.type + '*'),
9091
('fragment', lambda mo: mo.type + '_fragment')
9192
])
9293
def test_get_by_something(live_c8y: CumulocityApi, similar_objects: List[ManagedObject], key, value_fun):

tests/model/test_inventory.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Copyright (c) 2020 Software AG,
2+
# Darmstadt, Germany and/or Software AG USA Inc., Reston, VA, USA,
3+
# and/or its subsidiaries and/or its affiliates and/or their licensors.
4+
# Use, reproduction, transfer, publication or disclosure is prohibited except
5+
# as specifically provided for in your License Agreement with Software AG.
6+
from unittest.mock import Mock
7+
8+
import pytest
9+
from urllib import parse
10+
11+
from c8y_api import CumulocityRestApi
12+
from c8y_api.model import Inventory
13+
from c8y_api.model._util import _QueryUtil
14+
from tests.utils import isolate_last_call_arg
15+
16+
17+
@pytest.mark.parametrize('test, expected', [
18+
('string', 'string'),
19+
('with spaces', 'with spaces'),
20+
('quote\'s', 'quote\'\'s')
21+
])
22+
def test_encode_odata_query_value(test, expected):
23+
"""Verify that the query value encoding works as expected."""
24+
assert _QueryUtil.encode_odata_query_value(test) == expected
25+
26+
27+
@pytest.mark.parametrize('name, expected', [
28+
('some name', 'query=name eq \'some name\''),
29+
('some\'s name', 'query=name eq \'some\'\'s name\'')
30+
])
31+
def test_select_by_name(name, expected):
32+
"""Verify that the inventory's select function can filter by name."""
33+
34+
# In the end, the select function should result in a GET request; the
35+
# result of this is not important, we simulate an empty result set.
36+
c8y: CumulocityRestApi = Mock()
37+
c8y.get = Mock(return_value={'managedObjects': []})
38+
39+
inventory = Inventory(c8y)
40+
inventory.get_all(name=name)
41+
42+
assert c8y.get.call_count == 1
43+
url = parse.unquote_plus(isolate_last_call_arg(c8y.get, 'resource', 0))
44+
assert expected in url
45+
46+
47+
def test_select_by_name_plus():
48+
"""Verify that the inventory's select function will put all filters
49+
as parts of a complex query."""
50+
51+
c8y: CumulocityRestApi = Mock()
52+
c8y.get = Mock(return_value={'managedObjects': []})
53+
54+
inventory = Inventory(c8y)
55+
inventory.get_all(name='NAME', fragment='FRAGMENT', type='TYPE', owner='OWNER')
56+
57+
# we expect that the following strings are part of the resource string
58+
expected = [
59+
'query=$filter=(',
60+
'has(FRAGMENT)',
61+
'name eq \'NAME\'',
62+
'owner eq \'OWNER\'',
63+
'type eq \'TYPE\'']
64+
65+
assert c8y.get.call_count == 1
66+
url = parse.unquote_plus(isolate_last_call_arg(c8y.get, 'resource', 0))
67+
68+
for e in expected:
69+
assert e in url

tests/model/test_parser.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ def __init__(self):
3030
mapping = {
3131
'string_field': 'db_string',
3232
'int_field': 'db_int',
33-
'boolean_field': 'db_boolean'}
33+
'boolean_field': 'db_boolean',
34+
'special_field': 'db_special'}
3435

3536
return TestClass(), mapping
3637

@@ -47,13 +48,15 @@ def test_from_json_simple(simple_object_and_mapping):
4748
'db_int': random.randint(100, 200),
4849
'db_string': RandomNameGenerator.random_name(),
4950
'db_boolean': True,
51+
'db_special': "Special chars: ,._#'`\"?$%7{",
5052
'to_be_ignored_fragment': {'level': 2}}
5153

5254
parsed_obj = parser.from_json(source_json, obj)
5355

5456
assert parsed_obj.int_field == source_json['db_int']
5557
assert parsed_obj.string_field == source_json['db_string']
5658
assert parsed_obj.boolean_field == source_json['db_boolean']
59+
assert parsed_obj.special_field == source_json['db_special']
5760

5861

5962
def test_from_json_simple_skip(simple_object_and_mapping):

0 commit comments

Comments
 (0)