From f7a7c372e63f98142c6ef1c8651ed854a10ba095 Mon Sep 17 00:00:00 2001 From: Javier San Juan Cervera Date: Thu, 19 Sep 2019 16:08:29 +0200 Subject: [PATCH 1/5] Started rel module --- connect/rql/query.py | 62 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 connect/rql/query.py diff --git a/connect/rql/query.py b/connect/rql/query.py new file mode 100644 index 0000000..6f8ace6 --- /dev/null +++ b/connect/rql/query.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect SDK. +# Copyright (c) 2019 Ingram Micro. All Rights Reserved. + +from typing import Dict + + +class Query(object): + def __init__(self, properties=None): + """ + The Query class allows you to specify filters using the + `Resource Query Language `_ syntax. + :param dict properties: Initial list of input properties + """ + super(Query, self).__init__() + self._in = [] # type: Dict[str, list] + self._out = [] # type: Dict[str, list] + self._limit = '' # type: Dict[str, int] + self._sort = [] # type: list + self._like = [] # type: Dict[str, str] + self._select = [] # type: list + self._relationalOps = [] # type: Dict[str, list] + + if properties: + for key, value in properties.items(): + if isinstance(value, list): + self.in_(key, value) + else: + self.equal(key, value) + + def in_(self, prop, values): + self._in[prop] = values + return self + + def out(self, prop, values): + self._out[prop] = values + return self + + def limit(self, start, number): + self._limit = { + 'start': start, + 'number': number + } + return self + + def sort(self, properties): + self._sort = properties + return self + + def like(self, prop, pattern): + self._like[prop] = pattern + return self + + def select(self, attributes): + self._select = attributes + return self + + def equal(self, prop, value): + if 'eq' not in self._relationalOps: + self._relationalOps['eq'] = [] + self._relationalOps['eq'].append([prop, value]) From 14163642a10697ae62a0e96130743c0a89f0c784 Mon Sep 17 00:00:00 2001 From: Javier San Juan Cervera Date: Thu, 28 Nov 2019 12:08:15 +0100 Subject: [PATCH 2/5] Imported rql module in connect package. --- connect/__init__.py | 1 + connect/rql/__init__.py | 10 ++++++++++ docs/api_reference.rst | 6 ++++++ 3 files changed, 17 insertions(+) create mode 100644 connect/rql/__init__.py diff --git a/connect/__init__.py b/connect/__init__.py index ae84494..85282ad 100644 --- a/connect/__init__.py +++ b/connect/__init__.py @@ -42,6 +42,7 @@ def __init__(self, config=None): 'logger', 'models', 'resources', + 'rql', 'FulfillmentAutomation', 'TierConfigAutomation', ] diff --git a/connect/rql/__init__.py b/connect/rql/__init__.py new file mode 100644 index 0000000..977d942 --- /dev/null +++ b/connect/rql/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect SDK. +# Copyright (c) 2019 Ingram Micro. All Rights Reserved. + +from .query import Query + +__all__ = [ + 'Query' +] diff --git a/docs/api_reference.rst b/docs/api_reference.rst index d9ed512..c9d701c 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -24,3 +24,9 @@ resources .. automodule:: connect.resources :members: + +rql +=== + +.. automodule:: connect.rql + :members: From 5054319ef412d35cd013802d1289933c547bd8d0 Mon Sep 17 00:00:00 2001 From: Javier San Juan Cervera Date: Thu, 28 Nov 2019 17:31:33 +0100 Subject: [PATCH 3/5] Added Query class and unit tests --- connect/rql/query.py | 245 +++++++++++++++++++++++++++++++++++++++---- tests/test_query.py | 105 +++++++++++++++++++ 2 files changed, 328 insertions(+), 22 deletions(-) create mode 100644 tests/test_query.py diff --git a/connect/rql/query.py b/connect/rql/query.py index 6f8ace6..9757c95 100644 --- a/connect/rql/query.py +++ b/connect/rql/query.py @@ -3,7 +3,8 @@ # This file is part of the Ingram Micro Cloud Blue Connect SDK. # Copyright (c) 2019 Ingram Micro. All Rights Reserved. -from typing import Dict +from copy import copy +from typing import Dict, List, Optional class Query(object): @@ -14,49 +15,249 @@ def __init__(self, properties=None): :param dict properties: Initial list of input properties """ super(Query, self).__init__() - self._in = [] # type: Dict[str, list] - self._out = [] # type: Dict[str, list] - self._limit = '' # type: Dict[str, int] - self._sort = [] # type: list - self._like = [] # type: Dict[str, str] - self._select = [] # type: list - self._relationalOps = [] # type: Dict[str, list] + self._in = {} # type: Dict[str, List[str]] + self._out = {} # type: Dict[str, List[str]] + self._limit = None # type: Optional[int] + self._order_by = None # type: Optional[str] + self._offset = None # type: Optional[int] + self._ordering = None # type: Optional[List[str]] + self._like = {} # type: Dict[str, str] + self._ilike = {} # type: Dict[str, str] + self._select = None # type: Optional[List[str]] + self._rel_ops = {} # type: Dict[str, List[Dict[str, str]]] if properties: for key, value in properties.items(): - if isinstance(value, list): + if hasattr(self, '_' + key): + setattr(self, '_' + key, value) + elif isinstance(value, list): self.in_(key, value) else: self.equal(key, value) def in_(self, prop, values): - self._in[prop] = values + """ + Select objects where the specified property value is in the provided array. + :param str prop: Property + :param list values: Values + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + self._in[prop] = copy(values) return self def out(self, prop, values): - self._out[prop] = values + """ + Select objects where the specified property value is not in the provided array. + :param str prop: Property + :param list values: Values + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + self._out[prop] = copy(values) return self - def limit(self, start, number): - self._limit = { - 'start': start, - 'number': number - } + def limit(self, amount): + """ + Indicates the given number of objects from the start position. + :param int amount: Amount of objects to return. + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + self._limit = amount return self - def sort(self, properties): - self._sort = properties + def order_by(self, prop): + """ + Order list by given property. + :param str prop: Property. + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + self._order_by = prop + return self + + def offset(self, page): + """ + Offset (page) to return on paged queries. + :param int page: Offset. + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + self._offset = page + return self + + def ordering(self, props): + """ + Order list of objects by the given properties (unlimited number of properties). + The list is ordered first by the first specified property, then by the second, and + so on. The order is specified by the prefix: + ascending order, - descending. + :param list props: Properties. + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + self._ordering = copy(props) return self def like(self, prop, pattern): + """ + Search for the specified pattern in the specified property. The function is similar + to the SQL LIKE operator, though it uses the * wildcard instead of %. To specify in + a pattern the * symbol itself, it must be percent-encoded, that is, you need to specify + %2A instead of *, see the usage examples below. In addition, it is possible to use the + ? wildcard in the pattern to specify that any symbol will be valid in this position. + :param str prop: Property. + :param str pattern: Pattern. + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ self._like[prop] = pattern return self + def ilike(self, prop, pattern): + """ + Same as like but case unsensitive. + :param str prop: Property. + :param str pattern: Pattern. + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + self._ilike[prop] = pattern + return self + def select(self, attributes): - self._select = attributes + """ + The function is applicable to a list of resources (hereafter base resources). It receives + the list of attributes (up to 100 attributes) that can be primitive properties of the base + resources, relation names, and relation names combined with properties of related + resources. The output is the list of objects presenting the selected properties and related + (linked) resources. Normally, when relations are selected, the base resource properties are + also presented in the output. + :param list attributes: Attributes. + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + self._select = copy(attributes) return self def equal(self, prop, value): - if 'eq' not in self._relationalOps: - self._relationalOps['eq'] = [] - self._relationalOps['eq'].append([prop, value]) + """ + Select objects with a property value equal to value. + + :param str prop: Property. + :param str value: Value. + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + return self._add_rel_op('eq', prop, value) + + def not_equal(self, prop, value): + """ + Select objects with a property value not equal to value. + + :param str prop: Property. + :param str value: Value. + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + return self._add_rel_op('ne', prop, value) + + def greater(self, prop, value): + """ + Select objects with a property value greater than the value. + + :param str prop: Property. + :param str value: Value. + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + return self._add_rel_op('gt', prop, value) + + def greater_equal(self, prop, value): + """ + Select objects with a property value equal or greater than the value. + + :param str prop: Property. + :param str value: Value. + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + return self._add_rel_op('ge', prop, value) + + def lesser(self, prop, value): + """ + Select objects with a property value less than the value. + + :param str prop: Property. + :param str value: Value. + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + return self._add_rel_op('lt', prop, value) + + def lesser_equal(self, prop, value): + """ + Select objects with a property value equal or lesser than the value. + + :param str prop: Property. + :param str value: Value. + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + return self._add_rel_op('le', prop, value) + + def compile(self): + """ + :return: A string representation of the query. + :rtype: str + """ + rql = [] + + if self._select: + rql.append('select({})'.format(','.join(self._select))) + + for key, val in self._like.items(): + rql.append('like({},{})'.format(key, val)) + + for key, val in self._ilike.items(): + rql.append('ilike({},{})'.format(key, val)) + + for key, val in self._in.items(): + rql.append('in({},({}))'.format(key, ','.join(val))) + + for key, val in self._out.items(): + rql.append('out({},({}))'.format(key, ','.join(val))) + + for op, arguments in self._rel_ops.items(): + for argument in arguments: + rql.append('{}({},{})'.format(op, argument['key'], argument['value'])) + + if self._ordering: + rql.append('ordering({})'.format(','.join(self._ordering))) + + if self._limit: + rql.append('limit=' + str(self._limit)) + + if self._order_by: + rql.append('order_by=' + str(self._order_by)) + + if self._offset: + rql.append('offset=' + str(self._offset)) + + return ('?' + '&'.join(rql)) if rql else '' + + def _add_rel_op(self, op, prop, value): + """ + :param str op: + :param str prop: + :param str value: + :return: The Query object to provide a fluent interface (chaining method calls). + :rtype: :py:class:`.Query` + """ + if op not in self._rel_ops: + self._rel_ops[op] = [] + self._rel_ops[op].append({'key': prop, 'value': value}) + return self + + def __str__(self): + return self.compile() diff --git a/tests/test_query.py b/tests/test_query.py new file mode 100644 index 0000000..a56a08c --- /dev/null +++ b/tests/test_query.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect SDK. +# Copyright (c) 2019 Ingram Micro. All Rights Reserved. + +from connect.rql import Query + + +def test_empty_initializer(): + rql = Query() + assert rql.compile() == '' + + +def test_initializer_1(): + rql = Query({ + 'product.id': 'PRD-123123123', + 'ordering': ['test1', 'test2'], + 'limit': 10, + 'offset': 4, + 'order_by': 'property' + }) + assert rql.compile() == '?eq(product.id,PRD-123123123)&ordering(test1,test2)&limit=10'\ + '&order_by=property&offset=4' + + +def test_initializer_2(): + rql = Query({ + 'product.id': ['PRD-123123123', 'PRD-123123123'] + }) + assert rql.compile() == '?in(product.id,(PRD-123123123,PRD-123123123))' + + +def test_equal(): + rql = Query().equal('key', 'value') + assert rql.compile() == '?eq(key,value)' + + +def test_not_equal(): + rql = Query().not_equal('property', 'value') + assert rql.compile() == '?ne(property,value)' + + +def test_in(): + rql = Query().in_('key', ['value1', 'value2']) + assert rql.compile() == '?in(key,(value1,value2))' + + +def test_out(): + rql = Query().out('product.id', ['PR-', 'CN-']) + assert rql.compile() == '?out(product.id,(PR-,CN-))' + + +def test_select(): + rql = Query().select(['attribute']) + assert rql.compile() == '?select(attribute)' + + +def test_like(): + rql = Query().like('product.id', 'PR-') + assert rql.compile() == '?like(product.id,PR-)' + + +def test_ilike(): + rql = Query().ilike('product.id', 'PR-') + assert rql.compile() == '?ilike(product.id,PR-)' + + +def test_order_by(): + rql = Query().order_by('date') + assert rql.compile() == '?order_by=date' + + +def test_greater(): + rql = Query().greater('property', 'value') + assert rql.compile() == '?gt(property,value)' + + +def test_greater_equal(): + rql = Query().greater_equal('property', 'value') + assert rql.compile() == '?ge(property,value)' + + +def test_lesser(): + rql = Query().lesser('property', 'value') + assert rql.compile() == '?lt(property,value)' + + +def test_lesser_equal(): + rql = Query().lesser_equal('property', 'value') + assert rql.compile() == '?le(property,value)' + + +def test_limit(): + rql = Query().limit(10) + assert rql.compile() == '?limit=10' + + +def test_offset(): + rql = Query().offset(10) + assert rql.compile() == '?offset=10' + + +def test_ordering(): + rql = Query().ordering(['property1', 'property2']) + assert rql.compile() == '?ordering(property1,property2)' From 0a55e1d1ee3a9670444740c370f30d8736c1fcf1 Mon Sep 17 00:00:00 2001 From: Javier San Juan Cervera Date: Thu, 28 Nov 2019 18:22:52 +0100 Subject: [PATCH 4/5] Added RQL filters to Product and Directory classes. --- connect/models/product.py | 7 ++++-- connect/resources/directory.py | 39 ++++++++++++++++++++++------------ tests/test_directory.py | 8 +++---- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/connect/models/product.py b/connect/models/product.py index 27342bc..c12e32b 100644 --- a/connect/models/product.py +++ b/connect/models/product.py @@ -3,6 +3,7 @@ # This file is part of the Ingram Micro Cloud Blue Connect SDK. # Copyright (c) 2019 Ingram Micro. All Rights Reserved. +from copy import copy import datetime from typing import Optional @@ -17,6 +18,7 @@ from .template import Template from connect.config import Config from connect.resources.base import ApiClient +from connect.rql import Query class Product(BaseModel): @@ -80,7 +82,7 @@ def get_templates(self, config=None): def get_product_configurations(self, filters=None, config=None): """ - :param Dict[str, Any] filters: Filters for the requests. Supported filters are: + :param dict|Query filters: Filters for the requests. Supported filters are: - ``parameter.id`` - ``parameter.title`` - ``parameter.scope`` @@ -93,6 +95,7 @@ def get_product_configurations(self, filters=None, config=None): :return: A list with the product configuration parameter data. :rtype: List[ProductConfigurationParameter] """ + query = copy(filters) if isinstance(filters, Query) else Query(filters) text, _ = ApiClient(config or Config.get_instance(), - 'products/' + self.id + '/configurations').get(params=filters) + 'products/' + self.id + '/configurations' + query.compile()).get() return ProductConfigurationParameter.deserialize(text) diff --git a/connect/resources/directory.py b/connect/resources/directory.py index 3fc3c7a..f4ad552 100644 --- a/connect/resources/directory.py +++ b/connect/resources/directory.py @@ -3,11 +3,14 @@ # This file is part of the Ingram Micro Cloud Blue Connect SDK. # Copyright (c) 2019 Ingram Micro. All Rights Reserved. +from copy import copy + from connect.config import Config from connect.models.asset import Asset from connect.models.product import Product from connect.models.tier_config import TierConfig from connect.resources.base import ApiClient +from connect.rql import Query class Directory(object): @@ -24,15 +27,12 @@ def __init__(self, config=None): def list_assets(self, filters=None): """ List the assets. - :param (dict[str, Any] filters: Filters to pass to the request. + :param dict|Query filters: Filters to pass to the request. :return: A list with the assets that match the given filters. :rtype: list[Asset] """ - products = ','.join(self._config.products) if self._config.products else None - url = self._config.api_url + 'assets?in(product.id,(' + products + '))' \ - if products \ - else 'assets' - text, code = ApiClient(self._config, url).get(params=filters) + query = self._get_filters_query(filters, True) + text, code = ApiClient(self._config, 'assets' + query.compile()).get() return Asset.deserialize(text) def get_asset(self, asset_id): @@ -45,13 +45,15 @@ def get_asset(self, asset_id): text, code = ApiClient(self._config, 'assets/' + asset_id).get() return Asset.deserialize(text) - def list_products(self): - """ List the products. Filtering is not possible at the moment. + def list_products(self, filters=None): + """ List the products. + :param dict|Query filters: Filters to pass to the request. :return: A list with all products. :rtype: list[Product] """ - text, code = ApiClient(self._config, 'products').get() + query = self._get_filters_query(filters, False) + text, code = ApiClient(self._config, 'products' + query.compile()).get() return Product.deserialize(text) def get_product(self, product_id): @@ -71,11 +73,8 @@ def list_tier_configs(self, filters=None): :return: A list with the tier configs that match the given filters. :rtype: list[TierConfig] """ - filters = filters or {} - products_key = 'product.id' - if products_key not in filters and self._config.products: - filters[products_key] = ','.join(self._config.products) - text, code = ApiClient(self._config, 'tier/configs').get(params=filters) + query = self._get_filters_query(filters, True) + text, code = ApiClient(self._config, 'tier/configs' + query.compile()).get() return TierConfig.deserialize(text) def get_tier_config(self, tier_config_id): @@ -87,3 +86,15 @@ def get_tier_config(self, tier_config_id): """ text, code = ApiClient(self._config, 'tier/configs/' + tier_config_id).get() return TierConfig.deserialize(text) + + def _get_filters_query(self, filters, add_product): + """ + :param dict|Query filters: Filters to return as query (with product.id field). + :param bool add_product: Whether to add a product.id field to the query. + :return: The query. + :rtype: Query + """ + query = copy(filters) if isinstance(filters, Query) else Query(filters) + if add_product and self._config.products: + query.in_('product.id', self._config.products) + return query diff --git a/tests/test_directory.py b/tests/test_directory.py index 63203c9..6ceb4c1 100644 --- a/tests/test_directory.py +++ b/tests/test_directory.py @@ -64,8 +64,7 @@ def test_list_assets(get_mock): get_mock.assert_called_with( url='http://localhost:8080/api/public/v1/assets?in(product.id,(CN-631-322-000))', headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, - timeout=300, - params=None) + timeout=300) @patch('requests.get') @@ -131,10 +130,9 @@ def test_list_tier_configs(get_mock): assert tier_configs[0].id == 'TC-000-000-000' get_mock.assert_called_with( - url='http://localhost:8080/api/public/v1/tier/configs', + url='http://localhost:8080/api/public/v1/tier/configs?in(product.id,(CN-631-322-000))', headers={'Content-Type': 'application/json', 'Authorization': 'ApiKey XXXX:YYYYY'}, - timeout=300, - params={'product.id': 'CN-631-322-000'}) + timeout=300) @patch('requests.get') From 46bd5b85f9b6583a2c3116a90d4781e7aa4653b0 Mon Sep 17 00:00:00 2001 From: Javier San Juan Cervera Date: Fri, 29 Nov 2019 11:26:52 +0100 Subject: [PATCH 5/5] Fixed documentation of Query. --- connect/rql/query.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/connect/rql/query.py b/connect/rql/query.py index 9757c95..2730c3f 100644 --- a/connect/rql/query.py +++ b/connect/rql/query.py @@ -8,12 +8,14 @@ class Query(object): + """ + The Query class allows you to specify filters using the + `Resource Query Language `_ syntax. + + :param dict properties: Initial list of input properties + """ + def __init__(self, properties=None): - """ - The Query class allows you to specify filters using the - `Resource Query Language `_ syntax. - :param dict properties: Initial list of input properties - """ super(Query, self).__init__() self._in = {} # type: Dict[str, List[str]] self._out = {} # type: Dict[str, List[str]] @@ -38,6 +40,7 @@ def __init__(self, properties=None): def in_(self, prop, values): """ Select objects where the specified property value is in the provided array. + :param str prop: Property :param list values: Values :return: The Query object to provide a fluent interface (chaining method calls). @@ -49,6 +52,7 @@ def in_(self, prop, values): def out(self, prop, values): """ Select objects where the specified property value is not in the provided array. + :param str prop: Property :param list values: Values :return: The Query object to provide a fluent interface (chaining method calls). @@ -60,6 +64,7 @@ def out(self, prop, values): def limit(self, amount): """ Indicates the given number of objects from the start position. + :param int amount: Amount of objects to return. :return: The Query object to provide a fluent interface (chaining method calls). :rtype: :py:class:`.Query` @@ -70,6 +75,7 @@ def limit(self, amount): def order_by(self, prop): """ Order list by given property. + :param str prop: Property. :return: The Query object to provide a fluent interface (chaining method calls). :rtype: :py:class:`.Query` @@ -80,6 +86,7 @@ def order_by(self, prop): def offset(self, page): """ Offset (page) to return on paged queries. + :param int page: Offset. :return: The Query object to provide a fluent interface (chaining method calls). :rtype: :py:class:`.Query` @@ -92,6 +99,7 @@ def ordering(self, props): Order list of objects by the given properties (unlimited number of properties). The list is ordered first by the first specified property, then by the second, and so on. The order is specified by the prefix: + ascending order, - descending. + :param list props: Properties. :return: The Query object to provide a fluent interface (chaining method calls). :rtype: :py:class:`.Query` @@ -106,6 +114,7 @@ def like(self, prop, pattern): a pattern the * symbol itself, it must be percent-encoded, that is, you need to specify %2A instead of *, see the usage examples below. In addition, it is possible to use the ? wildcard in the pattern to specify that any symbol will be valid in this position. + :param str prop: Property. :param str pattern: Pattern. :return: The Query object to provide a fluent interface (chaining method calls). @@ -117,6 +126,7 @@ def like(self, prop, pattern): def ilike(self, prop, pattern): """ Same as like but case unsensitive. + :param str prop: Property. :param str pattern: Pattern. :return: The Query object to provide a fluent interface (chaining method calls). @@ -133,6 +143,7 @@ def select(self, attributes): resources. The output is the list of objects presenting the selected properties and related (linked) resources. Normally, when relations are selected, the base resource properties are also presented in the output. + :param list attributes: Attributes. :return: The Query object to provide a fluent interface (chaining method calls). :rtype: :py:class:`.Query`