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/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/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/connect/rql/query.py b/connect/rql/query.py new file mode 100644 index 0000000..2730c3f --- /dev/null +++ b/connect/rql/query.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- + +# 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 typing import Dict, List, Optional + + +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): + super(Query, self).__init__() + 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 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): + """ + 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): + """ + 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, 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 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): + """ + 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): + """ + 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/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: 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') 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)'