Support new v2 API image filters
Provide support in Glance API for querying the image list for created_at and updated_at times using guidance from the API Working Group recommendations for filtering. Filtering is applied at the DB layer. DocImpact ApiImpact MitakaPriority Change-Id: Ie94295bb82779ec17ab773928c71ae4a9ee8fbcc Implements bp: v2-additional-filtering
This commit is contained in:
parent
696671550e
commit
a4c6f12636
|
@ -176,6 +176,10 @@ class InvalidSwiftStoreConfiguration(Invalid):
|
||||||
message = _("Invalid configuration in glance-swift conf file.")
|
message = _("Invalid configuration in glance-swift conf file.")
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidFilterOperatorValue(Invalid):
|
||||||
|
message = _("Unable to filter using the specified operator.")
|
||||||
|
|
||||||
|
|
||||||
class InvalidFilterRangeValue(Invalid):
|
class InvalidFilterRangeValue(Invalid):
|
||||||
message = _("Unable to filter using the specified range.")
|
message = _("Unable to filter using the specified range.")
|
||||||
|
|
||||||
|
|
|
@ -648,3 +648,54 @@ def stash_conf_values():
|
||||||
}
|
}
|
||||||
|
|
||||||
return conf
|
return conf
|
||||||
|
|
||||||
|
|
||||||
|
def split_filter_op(expression):
|
||||||
|
"""Split operator from threshold in an expression.
|
||||||
|
Designed for use on a comparative-filtering query field.
|
||||||
|
When no operator is found, default to an equality comparison.
|
||||||
|
|
||||||
|
:param expression: the expression to parse
|
||||||
|
|
||||||
|
:returns a tuple (operator, threshold) parsed from expression
|
||||||
|
"""
|
||||||
|
left, sep, right = expression.partition(':')
|
||||||
|
if sep:
|
||||||
|
op = left
|
||||||
|
threshold = right
|
||||||
|
else:
|
||||||
|
op = 'eq' # default operator
|
||||||
|
threshold = left
|
||||||
|
|
||||||
|
# NOTE stevelle decoding escaped values may be needed later
|
||||||
|
return op, threshold
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_filter_op(value, operator, threshold):
|
||||||
|
"""Evaluate a comparison operator.
|
||||||
|
Designed for use on a comparative-filtering query field.
|
||||||
|
|
||||||
|
:param value: evaluated against the operator, as left side of expression
|
||||||
|
:param operator: any supported filter operation
|
||||||
|
:param threshold: to compare value against, as right side of expression
|
||||||
|
|
||||||
|
:raises InvalidFilterOperatorValue if an unknown operator is provided
|
||||||
|
|
||||||
|
:returns boolean result of applied comparison
|
||||||
|
|
||||||
|
"""
|
||||||
|
if operator == 'gt':
|
||||||
|
return value > threshold
|
||||||
|
elif operator == 'gte':
|
||||||
|
return value >= threshold
|
||||||
|
elif operator == 'lt':
|
||||||
|
return value < threshold
|
||||||
|
elif operator == 'lte':
|
||||||
|
return value <= threshold
|
||||||
|
elif operator == 'neq':
|
||||||
|
return value != threshold
|
||||||
|
elif operator == 'eq':
|
||||||
|
return value == threshold
|
||||||
|
|
||||||
|
msg = _("Unable to filter on a unknown operator.")
|
||||||
|
raise exception.InvalidFilterOperatorValue(msg)
|
||||||
|
|
|
@ -298,6 +298,13 @@ def _filter_images(images, filters, context,
|
||||||
to_add = image.get(key) >= value
|
to_add = image.get(key) >= value
|
||||||
elif k.endswith('_max'):
|
elif k.endswith('_max'):
|
||||||
to_add = image.get(key) <= value
|
to_add = image.get(key) <= value
|
||||||
|
elif k in ['created_at', 'updated_at']:
|
||||||
|
attr_value = image.get(key)
|
||||||
|
operator, isotime = utils.split_filter_op(value)
|
||||||
|
parsed_time = timeutils.parse_isotime(isotime)
|
||||||
|
threshold = timeutils.normalize_time(parsed_time)
|
||||||
|
to_add = utils.evaluate_filter_op(attr_value, operator,
|
||||||
|
threshold)
|
||||||
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':
|
||||||
|
|
|
@ -473,6 +473,14 @@ def _make_conditions_from_filters(filters, is_public=None):
|
||||||
image_conditions.append(getattr(models.Image, key) >= v)
|
image_conditions.append(getattr(models.Image, key) >= v)
|
||||||
if k.endswith('_max'):
|
if k.endswith('_max'):
|
||||||
image_conditions.append(getattr(models.Image, key) <= v)
|
image_conditions.append(getattr(models.Image, key) <= v)
|
||||||
|
elif k in ['created_at', 'updated_at']:
|
||||||
|
attr_value = getattr(models.Image, key)
|
||||||
|
operator, isotime = utils.split_filter_op(filters.pop(k))
|
||||||
|
parsed_time = timeutils.parse_isotime(isotime)
|
||||||
|
threshold = timeutils.normalize_time(parsed_time)
|
||||||
|
comparison = utils.evaluate_filter_op(attr_value, operator,
|
||||||
|
threshold)
|
||||||
|
image_conditions.append(comparison)
|
||||||
|
|
||||||
for (k, value) in filters.items():
|
for (k, value) in filters.items():
|
||||||
if hasattr(models.Image, k):
|
if hasattr(models.Image, k):
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
|
||||||
|
from sqlalchemy import MetaData, Table, Index
|
||||||
|
|
||||||
|
CREATED_AT_INDEX = 'created_at_image_idx'
|
||||||
|
UPDATED_AT_INDEX = 'updated_at_image_idx'
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade(migrate_engine):
|
||||||
|
meta = MetaData()
|
||||||
|
meta.bind = migrate_engine
|
||||||
|
|
||||||
|
images = Table('images', meta, autoload=True)
|
||||||
|
|
||||||
|
created_index = Index(CREATED_AT_INDEX, images.c.created_at)
|
||||||
|
created_index.create(migrate_engine)
|
||||||
|
updated_index = Index(UPDATED_AT_INDEX, images.c.updated_at)
|
||||||
|
updated_index.create(migrate_engine)
|
|
@ -121,7 +121,9 @@ class Image(BASE, GlanceBase):
|
||||||
__table_args__ = (Index('checksum_image_idx', 'checksum'),
|
__table_args__ = (Index('checksum_image_idx', 'checksum'),
|
||||||
Index('ix_images_is_public', 'is_public'),
|
Index('ix_images_is_public', 'is_public'),
|
||||||
Index('ix_images_deleted', 'deleted'),
|
Index('ix_images_deleted', 'deleted'),
|
||||||
Index('owner_image_idx', 'owner'),)
|
Index('owner_image_idx', 'owner'),
|
||||||
|
Index('created_at_image_idx', 'created_at'),
|
||||||
|
Index('updated_at_image_idx', 'updated_at'))
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True,
|
id = Column(String(36), primary_key=True,
|
||||||
default=lambda: str(uuid.uuid4()))
|
default=lambda: str(uuid.uuid4()))
|
||||||
|
|
|
@ -488,6 +488,22 @@ class DriverTests(object):
|
||||||
filters={'poo': 'bear'})
|
filters={'poo': 'bear'})
|
||||||
self.assertEqual(0, len(images))
|
self.assertEqual(0, len(images))
|
||||||
|
|
||||||
|
def test_image_get_all_with_filter_comparative_created_at(self):
|
||||||
|
anchor = timeutils.isotime(self.fixtures[0]['created_at'])
|
||||||
|
time_expr = 'lt:' + anchor
|
||||||
|
|
||||||
|
images = self.db_api.image_get_all(self.context,
|
||||||
|
filters={'created_at': time_expr})
|
||||||
|
self.assertEqual(0, len(images))
|
||||||
|
|
||||||
|
def test_image_get_all_with_filter_comparative_updated_at(self):
|
||||||
|
anchor = timeutils.isotime(self.fixtures[0]['updated_at'])
|
||||||
|
time_expr = 'lt:' + anchor
|
||||||
|
|
||||||
|
images = self.db_api.image_get_all(self.context,
|
||||||
|
filters={'updated_at': time_expr})
|
||||||
|
self.assertEqual(0, len(images))
|
||||||
|
|
||||||
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={
|
||||||
|
|
|
@ -22,6 +22,7 @@ import requests
|
||||||
import six
|
import six
|
||||||
# NOTE(jokke): simplified transition to py3, behaves like py2 xrange
|
# NOTE(jokke): simplified transition to py3, behaves like py2 xrange
|
||||||
from six.moves import range
|
from six.moves import range
|
||||||
|
from six.moves import urllib
|
||||||
|
|
||||||
from glance.tests import functional
|
from glance.tests import functional
|
||||||
from glance.tests import utils as test_utils
|
from glance.tests import utils as test_utils
|
||||||
|
@ -2452,6 +2453,26 @@ class TestImages(functional.FunctionalTest):
|
||||||
self.assertEqual('/v2/images', body['first'])
|
self.assertEqual('/v2/images', body['first'])
|
||||||
self.assertNotIn('next', jsonutils.loads(response.text))
|
self.assertNotIn('next', jsonutils.loads(response.text))
|
||||||
|
|
||||||
|
# Image list filters by created_at time
|
||||||
|
url_template = '/v2/images?created_at=lt:%s'
|
||||||
|
path = self._url(url_template % images[0]['created_at'])
|
||||||
|
response = requests.get(path, headers=self._headers())
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
body = jsonutils.loads(response.text)
|
||||||
|
self.assertEqual(0, len(body['images']))
|
||||||
|
self.assertEqual(url_template % images[0]['created_at'],
|
||||||
|
urllib.parse.unquote(body['first']))
|
||||||
|
|
||||||
|
# Image list filters by updated_at time
|
||||||
|
url_template = '/v2/images?updated_at=lt:%s'
|
||||||
|
path = self._url(url_template % images[2]['updated_at'])
|
||||||
|
response = requests.get(path, headers=self._headers())
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
body = jsonutils.loads(response.text)
|
||||||
|
self.assertGreaterEqual(3, len(body['images']))
|
||||||
|
self.assertEqual(url_template % images[2]['updated_at'],
|
||||||
|
urllib.parse.unquote(body['first']))
|
||||||
|
|
||||||
# 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')
|
||||||
|
|
|
@ -407,3 +407,78 @@ class TestUtils(test_utils.BaseTestCase):
|
||||||
self.assertRaises(ValueError,
|
self.assertRaises(ValueError,
|
||||||
utils.parse_valid_host_port,
|
utils.parse_valid_host_port,
|
||||||
pair)
|
pair)
|
||||||
|
|
||||||
|
|
||||||
|
class SplitFilterOpTestCase(test_utils.BaseTestCase):
|
||||||
|
|
||||||
|
def test_less_than_operator(self):
|
||||||
|
expr = 'lt:bar'
|
||||||
|
returned = utils.split_filter_op(expr)
|
||||||
|
self.assertEqual(('lt', 'bar'), returned)
|
||||||
|
|
||||||
|
def test_less_than_equal_operator(self):
|
||||||
|
expr = 'lte:bar'
|
||||||
|
returned = utils.split_filter_op(expr)
|
||||||
|
self.assertEqual(('lte', 'bar'), returned)
|
||||||
|
|
||||||
|
def test_greater_than_operator(self):
|
||||||
|
expr = 'gt:bar'
|
||||||
|
returned = utils.split_filter_op(expr)
|
||||||
|
self.assertEqual(('gt', 'bar'), returned)
|
||||||
|
|
||||||
|
def test_greater_than_equal_operator(self):
|
||||||
|
expr = 'gte:bar'
|
||||||
|
returned = utils.split_filter_op(expr)
|
||||||
|
self.assertEqual(('gte', 'bar'), returned)
|
||||||
|
|
||||||
|
def test_not_equal_operator(self):
|
||||||
|
expr = 'neq:bar'
|
||||||
|
returned = utils.split_filter_op(expr)
|
||||||
|
self.assertEqual(('neq', 'bar'), returned)
|
||||||
|
|
||||||
|
def test_equal_operator(self):
|
||||||
|
expr = 'eq:bar'
|
||||||
|
returned = utils.split_filter_op(expr)
|
||||||
|
self.assertEqual(('eq', 'bar'), returned)
|
||||||
|
|
||||||
|
def test_default_operator(self):
|
||||||
|
expr = 'bar'
|
||||||
|
returned = utils.split_filter_op(expr)
|
||||||
|
self.assertEqual(('eq', expr), returned)
|
||||||
|
|
||||||
|
|
||||||
|
class EvaluateFilterOpTestCase(test_utils.BaseTestCase):
|
||||||
|
|
||||||
|
def test_less_than_operator(self):
|
||||||
|
self.assertTrue(utils.evaluate_filter_op(9, 'lt', 10))
|
||||||
|
self.assertFalse(utils.evaluate_filter_op(10, 'lt', 10))
|
||||||
|
self.assertFalse(utils.evaluate_filter_op(11, 'lt', 10))
|
||||||
|
|
||||||
|
def test_less_than_equal_operator(self):
|
||||||
|
self.assertTrue(utils.evaluate_filter_op(9, 'lte', 10))
|
||||||
|
self.assertTrue(utils.evaluate_filter_op(10, 'lte', 10))
|
||||||
|
self.assertFalse(utils.evaluate_filter_op(11, 'lte', 10))
|
||||||
|
|
||||||
|
def test_greater_than_operator(self):
|
||||||
|
self.assertFalse(utils.evaluate_filter_op(9, 'gt', 10))
|
||||||
|
self.assertFalse(utils.evaluate_filter_op(10, 'gt', 10))
|
||||||
|
self.assertTrue(utils.evaluate_filter_op(11, 'gt', 10))
|
||||||
|
|
||||||
|
def test_greater_than_equal_operator(self):
|
||||||
|
self.assertFalse(utils.evaluate_filter_op(9, 'gte', 10))
|
||||||
|
self.assertTrue(utils.evaluate_filter_op(10, 'gte', 10))
|
||||||
|
self.assertTrue(utils.evaluate_filter_op(11, 'gte', 10))
|
||||||
|
|
||||||
|
def test_not_equal_operator(self):
|
||||||
|
self.assertTrue(utils.evaluate_filter_op(9, 'neq', 10))
|
||||||
|
self.assertFalse(utils.evaluate_filter_op(10, 'neq', 10))
|
||||||
|
self.assertTrue(utils.evaluate_filter_op(11, 'neq', 10))
|
||||||
|
|
||||||
|
def test_equal_operator(self):
|
||||||
|
self.assertFalse(utils.evaluate_filter_op(9, 'eq', 10))
|
||||||
|
self.assertTrue(utils.evaluate_filter_op(10, 'eq', 10))
|
||||||
|
self.assertFalse(utils.evaluate_filter_op(11, 'eq', 10))
|
||||||
|
|
||||||
|
def test_invalid_operator(self):
|
||||||
|
self.assertRaises(exception.InvalidFilterOperatorValue,
|
||||||
|
utils.evaluate_filter_op, '10', 'bar', '8')
|
||||||
|
|
Loading…
Reference in New Issue