diff --git a/neutron/db/tag_db.py b/neutron/db/tag_db.py index 69426f6ceb6..fff63100349 100644 --- a/neutron/db/tag_db.py +++ b/neutron/db/tag_db.py @@ -14,6 +14,7 @@ import sqlalchemy as sa from sqlalchemy import orm +from sqlalchemy.orm import aliased from neutron.db import model_base @@ -27,3 +28,75 @@ class Tag(model_base.BASEV2): standard_attr = orm.relationship( 'StandardAttribute', backref=orm.backref('tags', lazy='joined', viewonly=True)) + + +def _get_tag_list(tag_strings): + tags = set() + for tag_str in tag_strings: + tags |= set(tag_str.split(',')) + return list(tags) + + +def apply_tag_filters(model, query, filters): + """Apply tag filters + + There are four types of filter: + `tags` -- One or more strings that will be used to filter results + in an AND expression: T1 AND T2 + + `tags-any` -- One or more strings that will be used to filter results + in an OR expression: T1 OR T2 + + `not-tags` -- One or more strings that will be used to filter results + in a NOT AND expression: NOT (T1 AND T2) + + `not-tags-any` -- One or more strings that will be used to filter results + in a NOT OR expression: NOT (T1 OR T2) + + Note: tag values can be specified comma separated string. + for example, + 'GET /v2.0/networks?tags-any=red,blue' is equivalent to + 'GET /v2.0/networks?tags-any=red&tags-any=blue' + it means 'red' or 'blue'. + """ + + if 'tags' in filters: + tags = _get_tag_list(filters.pop('tags')) + first_tag = tags.pop(0) + query = query.join(Tag, + model.standard_attr_id == Tag.standard_attr_id) + query = query.filter(Tag.tag == first_tag) + + for tag in tags: + tag_alias = aliased(Tag) + query = query.join(tag_alias, + model.standard_attr_id == tag_alias.standard_attr_id) + query = query.filter(tag_alias.tag == tag) + + if 'tags-any' in filters: + tags = _get_tag_list(filters.pop('tags-any')) + query = query.join(Tag, + model.standard_attr_id == Tag.standard_attr_id) + query = query.filter(Tag.tag.in_(tags)) + + if 'not-tags' in filters: + tags = _get_tag_list(filters.pop('not-tags')) + first_tag = tags.pop(0) + subq = query.session.query(Tag.standard_attr_id) + subq = subq.filter(Tag.tag == first_tag) + + for tag in tags: + tag_alias = aliased(Tag) + subq = subq.join(tag_alias, + Tag.standard_attr_id == tag_alias.standard_attr_id) + subq = subq.filter(tag_alias.tag == tag) + + query = query.filter(~model.standard_attr_id.in_(subq)) + + if 'not-tags-any' in filters: + tags = _get_tag_list(filters.pop('not-tags-any')) + subq = query.session.query(Tag.standard_attr_id) + subq = subq.filter(Tag.tag.in_(tags)) + query = query.filter(~model.standard_attr_id.in_(subq)) + + return query diff --git a/neutron/services/tag/tag_plugin.py b/neutron/services/tag/tag_plugin.py index d7962974140..8f11ca693ae 100644 --- a/neutron/services/tag/tag_plugin.py +++ b/neutron/services/tag/tag_plugin.py @@ -12,6 +12,8 @@ # under the License. # +import functools + from oslo_db import api as oslo_db_api from oslo_db import exception as db_exc from oslo_log import helpers as log_helpers @@ -118,6 +120,9 @@ class TagPlugin(common_db_mixin.CommonDbMixin, tag_ext.TagPluginBase): # support only _apply_dict_extend_functions supported resources # at the moment. - for resource in resource_model_map: + for resource, model in resource_model_map.items(): common_db_mixin.CommonDbMixin.register_dict_extend_funcs( resource, [_extend_tags_dict]) + common_db_mixin.CommonDbMixin.register_model_query_hook( + model, "tag", None, None, + functools.partial(tag_model.apply_tag_filters, model)) diff --git a/neutron/tests/unit/extensions/test_tag.py b/neutron/tests/unit/extensions/test_tag.py index 494d73cd06f..3e4632146aa 100644 --- a/neutron/tests/unit/extensions/test_tag.py +++ b/neutron/tests/unit/extensions/test_tag.py @@ -65,6 +65,28 @@ class TestTagApiBase(test_db_base_plugin_v2.NeutronDbPluginV2TestCase): def _assertEqualTags(self, expected, actual): self.assertEqual(set(expected), set(actual)) + def _make_query_string(self, tags, tags_any, not_tags, not_tags_any): + filter_strings = [] + if tags: + filter_strings.append("tags=" + ','.join(tags)) + if tags_any: + filter_strings.append("tags-any=" + ','.join(tags_any)) + if not_tags: + filter_strings.append("not-tags=" + ','.join(not_tags)) + if not_tags_any: + filter_strings.append("not-tags-any=" + ','.join(not_tags_any)) + + return '&'.join(filter_strings) + + def _get_tags_filter_resources(self, tags=None, tags_any=None, + not_tags=None, not_tags_any=None): + params = self._make_query_string(tags, tags_any, not_tags, + not_tags_any) + req = self._req('GET', self.resource, params=params) + res = req.get_response(self.api) + res = self.deserialize(self.fmt, res) + return res[self.resource] + class TestNetworkTagApi(TestTagApiBase): resource = 'networks' @@ -153,3 +175,68 @@ class TestNetworkTagApi(TestTagApiBase): self.assertEqual(204, res.status_int) tags = self._get_resource_tags(net_id) self._assertEqualTags([], tags) + + +class TestNetworkTagFilter(TestTagApiBase): + resource = 'networks' + member = 'network' + + def setUp(self): + super(TestNetworkTagFilter, self).setUp() + self._prepare_network_tags() + + def _prepare_network_tags(self): + res = self._make_network(self.fmt, 'net1', True) + net1_id = res['network']['id'] + res = self._make_network(self.fmt, 'net2', True) + net2_id = res['network']['id'] + res = self._make_network(self.fmt, 'net3', True) + net3_id = res['network']['id'] + res = self._make_network(self.fmt, 'net4', True) + net4_id = res['network']['id'] + res = self._make_network(self.fmt, 'net5', True) + net5_id = res['network']['id'] + + self._put_tags(net1_id, ['red']) + self._put_tags(net2_id, ['red', 'blue']) + self._put_tags(net3_id, ['red', 'blue', 'green']) + self._put_tags(net4_id, ['green']) + # net5: no tags + tags = self._get_resource_tags(net5_id) + self._assertEqualTags([], tags) + + def _assertEqualResources(self, expected, res): + actual = [n['name'] for n in res] + self.assertEqual(set(expected), set(actual)) + + def test_filter_tags_single(self): + res = self._get_tags_filter_resources(tags=['red']) + self._assertEqualResources(['net1', 'net2', 'net3'], res) + + def test_filter_tags_multi(self): + res = self._get_tags_filter_resources(tags=['red', 'blue']) + self._assertEqualResources(['net2', 'net3'], res) + + def test_filter_tags_any_single(self): + res = self._get_tags_filter_resources(tags_any=['blue']) + self._assertEqualResources(['net2', 'net3'], res) + + def test_filter_tags_any_multi(self): + res = self._get_tags_filter_resources(tags_any=['red', 'blue']) + self._assertEqualResources(['net1', 'net2', 'net3'], res) + + def test_filter_not_tags_single(self): + res = self._get_tags_filter_resources(not_tags=['red']) + self._assertEqualResources(['net4', 'net5'], res) + + def test_filter_not_tags_multi(self): + res = self._get_tags_filter_resources(not_tags=['red', 'blue']) + self._assertEqualResources(['net1', 'net4', 'net5'], res) + + def test_filter_not_tags_any_single(self): + res = self._get_tags_filter_resources(not_tags_any=['blue']) + self._assertEqualResources(['net1', 'net4', 'net5'], res) + + def test_filter_not_tags_any_multi(self): + res = self._get_tags_filter_resources(not_tags_any=['red', 'blue']) + self._assertEqualResources(['net4', 'net5'], res) diff --git a/releasenotes/notes/add-tags-to-core-resources-b05330a129900609.yaml b/releasenotes/notes/add-tags-to-core-resources-b05330a129900609.yaml index 95fc08b7051..6e56377e340 100644 --- a/releasenotes/notes/add-tags-to-core-resources-b05330a129900609.yaml +++ b/releasenotes/notes/add-tags-to-core-resources-b05330a129900609.yaml @@ -3,3 +3,5 @@ prelude: > Add tag mechanism for network resources features: - Users can set tags on their network resources. + - Networks can be filtered by tags. The supported filters are + 'tags', 'tags-any', 'not-tags' and 'not-tags-any'.