Added support new v2 API image filters
Added support filtering images based on lists using the 'in' operator. Filters: *id *name *container_format *disk_format *status DocImpact ApiImpact Implements bp: in-filtering-operator Change-Id: I9cac81b9d5cbec979e88cf2dd0e3b710ed45630c
This commit is contained in:
parent
ad75afad0f
commit
6007061943
@ -607,6 +607,52 @@ def split_filter_op(expression):
|
|||||||
return op, threshold
|
return op, threshold
|
||||||
|
|
||||||
|
|
||||||
|
def validate_quotes(value):
|
||||||
|
"""Validate filter values
|
||||||
|
|
||||||
|
Validation opening/closing quotes in the expression.
|
||||||
|
"""
|
||||||
|
open_quotes = True
|
||||||
|
for i in range(len(value)):
|
||||||
|
if value[i] == '"':
|
||||||
|
if i and value[i - 1] == '\\':
|
||||||
|
continue
|
||||||
|
if open_quotes:
|
||||||
|
if i and value[i - 1] != ',':
|
||||||
|
msg = _("Invalid filter value %s. There is no comma "
|
||||||
|
"before opening quotation mark.") % value
|
||||||
|
raise exception.InvalidParameterValue(message=msg)
|
||||||
|
else:
|
||||||
|
if i + 1 != len(value) and value[i + 1] != ",":
|
||||||
|
msg = _("Invalid filter value %s. There is no comma "
|
||||||
|
"after closing quotation mark.") % value
|
||||||
|
raise exception.InvalidParameterValue(message=msg)
|
||||||
|
open_quotes = not open_quotes
|
||||||
|
if not open_quotes:
|
||||||
|
msg = _("Invalid filter value %s. The quote is not closed.") % value
|
||||||
|
raise exception.InvalidParameterValue(message=msg)
|
||||||
|
|
||||||
|
|
||||||
|
def split_filter_value_for_quotes(value):
|
||||||
|
"""Split filter values
|
||||||
|
|
||||||
|
Split values by commas and quotes for 'in' operator, according api-wg.
|
||||||
|
"""
|
||||||
|
validate_quotes(value)
|
||||||
|
tmp = re.compile(r'''
|
||||||
|
"( # if found a double-quote
|
||||||
|
[^\"\\]* # take characters either non-quotes or backslashes
|
||||||
|
(?:\\. # take backslashes and character after it
|
||||||
|
[^\"\\]*)* # take characters either non-quotes or backslashes
|
||||||
|
) # before double-quote
|
||||||
|
",? # a double-quote with comma maybe
|
||||||
|
| ([^,]+),? # if not found double-quote take any non-comma
|
||||||
|
# characters with comma maybe
|
||||||
|
| , # if we have only comma take empty string
|
||||||
|
''', re.VERBOSE)
|
||||||
|
return [val[0] or val[1] for val in re.findall(tmp, value)]
|
||||||
|
|
||||||
|
|
||||||
def evaluate_filter_op(value, operator, threshold):
|
def evaluate_filter_op(value, operator, threshold):
|
||||||
"""Evaluate a comparison operator.
|
"""Evaluate a comparison operator.
|
||||||
Designed for use on a comparative-filtering query field.
|
Designed for use on a comparative-filtering query field.
|
||||||
|
@ -305,6 +305,20 @@ def _filter_images(images, filters, context,
|
|||||||
threshold = timeutils.normalize_time(parsed_time)
|
threshold = timeutils.normalize_time(parsed_time)
|
||||||
to_add = utils.evaluate_filter_op(attr_value, operator,
|
to_add = utils.evaluate_filter_op(attr_value, operator,
|
||||||
threshold)
|
threshold)
|
||||||
|
elif k in ['name', 'id', 'status',
|
||||||
|
'container_format', 'disk_format']:
|
||||||
|
attr_value = image.get(key)
|
||||||
|
operator, list_value = utils.split_filter_op(value)
|
||||||
|
if operator == 'in':
|
||||||
|
threshold = utils.split_filter_value_for_quotes(list_value)
|
||||||
|
to_add = attr_value in threshold
|
||||||
|
elif operator == 'eq':
|
||||||
|
to_add = (attr_value == list_value)
|
||||||
|
else:
|
||||||
|
msg = (_("Unable to filter by unknown operator '%s'.")
|
||||||
|
% operator)
|
||||||
|
raise exception.InvalidFilterOperatorValue(msg)
|
||||||
|
|
||||||
elif k != 'is_public' and image.get(k) is not None:
|
elif k != 'is_public' and image.get(k) is not None:
|
||||||
to_add = image.get(key) == value
|
to_add = image.get(key) == value
|
||||||
elif k == 'tags':
|
elif k == 'tags':
|
||||||
|
@ -510,6 +510,20 @@ def _make_conditions_from_filters(filters, is_public=None):
|
|||||||
threshold)
|
threshold)
|
||||||
image_conditions.append(comparison)
|
image_conditions.append(comparison)
|
||||||
|
|
||||||
|
elif k in ['name', 'id', 'status', 'container_format', 'disk_format']:
|
||||||
|
attr_value = getattr(models.Image, key)
|
||||||
|
operator, list_value = utils.split_filter_op(filters.pop(k))
|
||||||
|
if operator == 'in':
|
||||||
|
threshold = utils.split_filter_value_for_quotes(list_value)
|
||||||
|
comparison = attr_value.in_(threshold)
|
||||||
|
image_conditions.append(comparison)
|
||||||
|
elif operator == 'eq':
|
||||||
|
image_conditions.append(attr_value == list_value)
|
||||||
|
else:
|
||||||
|
msg = (_("Unable to filter by unknown operator '%s'.")
|
||||||
|
% operator)
|
||||||
|
raise exception.InvalidFilterOperatorValue(msg)
|
||||||
|
|
||||||
for (k, value) in filters.items():
|
for (k, value) in filters.items():
|
||||||
if hasattr(models.Image, k):
|
if hasattr(models.Image, k):
|
||||||
image_conditions.append(getattr(models.Image, k) == value)
|
image_conditions.append(getattr(models.Image, k) == value)
|
||||||
|
@ -505,6 +505,64 @@ class DriverTests(object):
|
|||||||
filters={'updated_at': time_expr})
|
filters={'updated_at': time_expr})
|
||||||
self.assertEqual(0, len(images))
|
self.assertEqual(0, len(images))
|
||||||
|
|
||||||
|
def test_filter_image_by_invalid_operator(self):
|
||||||
|
self.assertRaises(exception.InvalidFilterOperatorValue,
|
||||||
|
self.db_api.image_get_all,
|
||||||
|
self.context, filters={'status': 'lala:active'})
|
||||||
|
|
||||||
|
def test_image_get_all_with_filter_in_status(self):
|
||||||
|
images = self.db_api.image_get_all(self.context,
|
||||||
|
filters={'status': 'in:active'})
|
||||||
|
self.assertEqual(3, len(images))
|
||||||
|
|
||||||
|
def test_image_get_all_with_filter_in_name(self):
|
||||||
|
data = 'in:%s' % self.fixtures[0]['name']
|
||||||
|
images = self.db_api.image_get_all(self.context,
|
||||||
|
filters={'name': data})
|
||||||
|
self.assertEqual(3, len(images))
|
||||||
|
|
||||||
|
def test_image_get_all_with_filter_in_container_format(self):
|
||||||
|
images = self.db_api.image_get_all(self.context,
|
||||||
|
filters={'container_format':
|
||||||
|
'in:ami,bare,ovf'})
|
||||||
|
self.assertEqual(3, len(images))
|
||||||
|
|
||||||
|
def test_image_get_all_with_filter_in_disk_format(self):
|
||||||
|
images = self.db_api.image_get_all(self.context,
|
||||||
|
filters={'disk_format':
|
||||||
|
'in:vhd'})
|
||||||
|
self.assertEqual(3, len(images))
|
||||||
|
|
||||||
|
def test_image_get_all_with_filter_in_id(self):
|
||||||
|
data = 'in:%s,%s' % (UUID1, UUID2)
|
||||||
|
images = self.db_api.image_get_all(self.context,
|
||||||
|
filters={'id': data})
|
||||||
|
self.assertEqual(2, len(images))
|
||||||
|
|
||||||
|
def test_image_get_all_with_quotes(self):
|
||||||
|
fixture = {'name': 'fake\\\"name'}
|
||||||
|
self.db_api.image_update(self.adm_context, UUID3, fixture)
|
||||||
|
|
||||||
|
fixture = {'name': 'fake,name'}
|
||||||
|
self.db_api.image_update(self.adm_context, UUID2, fixture)
|
||||||
|
|
||||||
|
fixture = {'name': 'fakename'}
|
||||||
|
self.db_api.image_update(self.adm_context, UUID1, fixture)
|
||||||
|
|
||||||
|
data = 'in:\"fake\\\"name\",fakename,\"fake,name\"'
|
||||||
|
|
||||||
|
images = self.db_api.image_get_all(self.context,
|
||||||
|
filters={'name': data})
|
||||||
|
self.assertEqual(3, len(images))
|
||||||
|
|
||||||
|
def test_image_get_all_with_invalid_quotes(self):
|
||||||
|
invalid_expr = ['in:\"name', 'in:\"name\"name', 'in:name\"dd\"',
|
||||||
|
'in:na\"me', 'in:\"name\"\"name\"']
|
||||||
|
for expr in invalid_expr:
|
||||||
|
self.assertRaises(exception.InvalidParameterValue,
|
||||||
|
self.db_api.image_get_all,
|
||||||
|
self.context, filters={'name': expr})
|
||||||
|
|
||||||
def test_image_get_all_size_min_max(self):
|
def test_image_get_all_size_min_max(self):
|
||||||
images = self.db_api.image_get_all(self.context,
|
images = self.db_api.image_get_all(self.context,
|
||||||
filters={
|
filters={
|
||||||
|
@ -2585,13 +2585,15 @@ class TestImages(functional.FunctionalTest):
|
|||||||
# Create 7 images
|
# Create 7 images
|
||||||
images = []
|
images = []
|
||||||
fixtures = [
|
fixtures = [
|
||||||
{'name': 'image-3', 'type': 'kernel', 'ping': 'pong'},
|
{'name': 'image-3', 'type': 'kernel', 'ping': 'pong',
|
||||||
{'name': 'image-4', 'type': 'kernel', 'ping': 'pong'},
|
'container_format': 'ami', 'disk_format': 'ami'},
|
||||||
|
{'name': 'image-4', 'type': 'kernel', 'ping': 'pong',
|
||||||
|
'container_format': 'bare', 'disk_format': 'ami'},
|
||||||
{'name': 'image-1', 'type': 'kernel', 'ping': 'pong'},
|
{'name': 'image-1', 'type': 'kernel', 'ping': 'pong'},
|
||||||
{'name': 'image-3', 'type': 'ramdisk', 'ping': 'pong'},
|
{'name': 'image-3', 'type': 'ramdisk', 'ping': 'pong'},
|
||||||
{'name': 'image-2', 'type': 'kernel', 'ping': 'ding'},
|
{'name': 'image-2', 'type': 'kernel', 'ping': 'ding'},
|
||||||
{'name': 'image-3', 'type': 'kernel', 'ping': 'pong'},
|
{'name': 'image-3', 'type': 'kernel', 'ping': 'pong'},
|
||||||
{'name': 'image-2', 'type': 'kernel', 'ping': 'pong'},
|
{'name': 'image-2,image-5', 'type': 'kernel', 'ping': 'pong'},
|
||||||
]
|
]
|
||||||
path = self._url('/v2/images')
|
path = self._url('/v2/images')
|
||||||
headers = self._headers({'content-type': 'application/json'})
|
headers = self._headers({'content-type': 'application/json'})
|
||||||
@ -2649,6 +2651,33 @@ class TestImages(functional.FunctionalTest):
|
|||||||
response = requests.get(path, headers=self._headers())
|
response = requests.get(path, headers=self._headers())
|
||||||
self.assertEqual(400, response.status_code)
|
self.assertEqual(400, response.status_code)
|
||||||
|
|
||||||
|
# Image list filters by name with in operator
|
||||||
|
url_template = '/v2/images?name=in:%s'
|
||||||
|
filter_value = 'image-1,image-2'
|
||||||
|
path = self._url(url_template % filter_value)
|
||||||
|
response = requests.get(path, headers=self._headers())
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
body = jsonutils.loads(response.text)
|
||||||
|
self.assertGreaterEqual(3, len(body['images']))
|
||||||
|
|
||||||
|
# Image list filters by container_format with in operator
|
||||||
|
url_template = '/v2/images?container_format=in:%s'
|
||||||
|
filter_value = 'bare,ami'
|
||||||
|
path = self._url(url_template % filter_value)
|
||||||
|
response = requests.get(path, headers=self._headers())
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
body = jsonutils.loads(response.text)
|
||||||
|
self.assertGreaterEqual(2, len(body['images']))
|
||||||
|
|
||||||
|
# Image list filters by disk_format with in operator
|
||||||
|
url_template = '/v2/images?disk_format=in:%s'
|
||||||
|
filter_value = 'bare,ami,iso'
|
||||||
|
path = self._url(url_template % filter_value)
|
||||||
|
response = requests.get(path, headers=self._headers())
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
body = jsonutils.loads(response.text)
|
||||||
|
self.assertGreaterEqual(2, len(body['images']))
|
||||||
|
|
||||||
# Begin pagination after the first image
|
# Begin pagination after the first image
|
||||||
template_url = ('/v2/images?limit=2&sort_dir=asc&sort_key=name'
|
template_url = ('/v2/images?limit=2&sort_dir=asc&sort_key=name'
|
||||||
'&marker=%s&type=kernel&ping=pong')
|
'&marker=%s&type=kernel&ping=pong')
|
||||||
|
@ -423,6 +423,28 @@ class SplitFilterOpTestCase(test_utils.BaseTestCase):
|
|||||||
returned = utils.split_filter_op(expr)
|
returned = utils.split_filter_op(expr)
|
||||||
self.assertEqual(('eq', 'bar'), returned)
|
self.assertEqual(('eq', 'bar'), returned)
|
||||||
|
|
||||||
|
def test_in_operator(self):
|
||||||
|
expr = 'in:bar'
|
||||||
|
returned = utils.split_filter_op(expr)
|
||||||
|
self.assertEqual(('in', 'bar'), returned)
|
||||||
|
|
||||||
|
def test_split_filter_value_for_quotes(self):
|
||||||
|
expr = '\"fake\\\"name\",fakename,\"fake,name\"'
|
||||||
|
returned = utils.split_filter_value_for_quotes(expr)
|
||||||
|
list_values = ['fake\\"name', 'fakename', 'fake,name']
|
||||||
|
self.assertEqual(list_values, returned)
|
||||||
|
|
||||||
|
def test_validate_quotes(self):
|
||||||
|
expr = '\"aaa\\\"aa\",bb,\"cc\"'
|
||||||
|
returned = utils.validate_quotes(expr)
|
||||||
|
self.assertIsNone(returned)
|
||||||
|
|
||||||
|
invalid_expr = ['\"aa', 'ss\"', 'aa\"bb\"cc', '\"aa\"\"bb\"']
|
||||||
|
for expr in invalid_expr:
|
||||||
|
self.assertRaises(exception.InvalidParameterValue,
|
||||||
|
utils.validate_quotes,
|
||||||
|
expr)
|
||||||
|
|
||||||
def test_default_operator(self):
|
def test_default_operator(self):
|
||||||
expr = 'bar'
|
expr = 'bar'
|
||||||
returned = utils.split_filter_op(expr)
|
returned = utils.split_filter_op(expr)
|
||||||
|
16
releasenotes/notes/new_image_filters-c888361e6ecf495c.yaml
Normal file
16
releasenotes/notes/new_image_filters-c888361e6ecf495c.yaml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
- Implement the ability to filter images by the properties `id`,
|
||||||
|
`name`, `status`,`container_format`, `disk_format` using the 'in'
|
||||||
|
operator between the values.
|
||||||
|
Following the pattern of existing filters, new filters are specified as
|
||||||
|
query parameters using the field to filter as the key and the filter
|
||||||
|
criteria as the value in the parameter.
|
||||||
|
Filtering based on the principle of full compliance with the template,
|
||||||
|
for example 'name = in:deb' does not match 'debian'.
|
||||||
|
Changes apply exclusively to the API v2 Image entity listings
|
||||||
|
An example of an acceptance criteria using the 'in' operator for name
|
||||||
|
?name=in:name1,name2,name3.
|
||||||
|
These filters were added using syntax that conforms to the latest
|
||||||
|
guidelines from the OpenStack API Working Group.
|
||||||
|
|
Loading…
x
Reference in New Issue
Block a user