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:
Steve Lewis 2015-06-30 11:16:01 -07:00
parent 696671550e
commit a4c6f12636
9 changed files with 214 additions and 1 deletions

View File

@ -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.")

View File

@ -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)

View File

@ -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':

View File

@ -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):

View File

@ -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)

View File

@ -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()))

View File

@ -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={

View File

@ -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')

View File

@ -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')