File 0001-enable-sql-metadata-query.patch of Package openstack-ceilometer

From 02d93a52fa68d2ed75f0fbb1cb00f9aebaa5069c Mon Sep 17 00:00:00 2001
From: Gordon Chung <chungg@ca.ibm.com>
Date: Wed, 2 Oct 2013 15:45:26 -0400
Subject: [PATCH] enable sql metadata query

explode metadata key/values to their own tables/rows (based on type).
build a key string using dot notation similar to other nosql db
and filter based on that.

Blueprint: sqlalchemy-metadata-query
Related-Bug: #1093625

Change-Id: I2076e67b79448f98124a57b62b5bfed7aa8ae2ad
(cherry picked from commit 1570462507eae1478123de25dbadc64b09c82af3)
---
 ceilometer/storage/impl_sqlalchemy.py              | 79 +++++++++++++++++++---
 .../versions/020_add_metadata_tables.py            | 78 +++++++++++++++++++++
 ceilometer/storage/sqlalchemy/models.py            | 48 +++++++++++++
 ceilometer/utils.py                                | 24 +++++++
 doc/source/install/dbreco.rst                      |  4 +-
 tests/api/v2/test_list_meters_scenarios.py         |  1 +
 tests/test_utils.py                                | 16 +++++
 7 files changed, 239 insertions(+), 11 deletions(-)
 create mode 100644 ceilometer/storage/sqlalchemy/migrate_repo/versions/020_add_metadata_tables.py

diff --git a/ceilometer/storage/impl_sqlalchemy.py b/ceilometer/storage/impl_sqlalchemy.py
index 08ffb47..9ede0f3 100644
--- a/ceilometer/storage/impl_sqlalchemy.py
+++ b/ceilometer/storage/impl_sqlalchemy.py
@@ -18,10 +18,12 @@
 """SQLAlchemy storage backend."""
 
 from __future__ import absolute_import
-
 import datetime
 import operator
 import os
+import types
+
+from sqlalchemy import and_
 from sqlalchemy import func
 from sqlalchemy import desc
 from sqlalchemy.orm import aliased
@@ -39,6 +41,10 @@ from ceilometer.storage.sqlalchemy.models import AlarmChange
 from ceilometer.storage.sqlalchemy.models import Base
 from ceilometer.storage.sqlalchemy.models import Event
 from ceilometer.storage.sqlalchemy.models import Meter
+from ceilometer.storage.sqlalchemy.models import MetaBool
+from ceilometer.storage.sqlalchemy.models import MetaFloat
+from ceilometer.storage.sqlalchemy.models import MetaInt
+from ceilometer.storage.sqlalchemy.models import MetaText
 from ceilometer.storage.sqlalchemy.models import Project
 from ceilometer.storage.sqlalchemy.models import Resource
 from ceilometer.storage.sqlalchemy.models import Source
@@ -100,7 +106,40 @@ class SQLAlchemyStorage(base.StorageEngine):
         return Connection(conf)
 
 
-def make_query_from_filter(query, sample_filter, require_meter=True):
+META_TYPE_MAP = {bool: MetaBool,
+                 str: MetaText,
+                 unicode: MetaText,
+                 types.NoneType: MetaText,
+                 int: MetaInt,
+                 long: MetaInt,
+                 float: MetaFloat}
+
+
+def apply_metaquery_filter(session, query, metaquery):
+    """Apply provided metaquery filter to existing query.
+
+    :param session: session used for original query
+    :param query: Query instance
+    :param metaquery: dict with metadata to match on.
+    """
+
+    for k, v in metaquery.iteritems():
+        key = k[9:]  # strip out 'metadata.' prefix
+        try:
+            _model = META_TYPE_MAP[type(v)]
+        except KeyError:
+            raise NotImplementedError(_('Query on %(key)s is of %(value)s '
+                                        'type and is not supported') %
+                                      {"key": k, "value": type(v)})
+        else:
+            meta_q = session.query(_model).\
+                filter(and_(_model.meta_key == key,
+                            _model.value == v)).subquery()
+            query = query.filter_by(id=meta_q.c.id)
+    return query
+
+
+def make_query_from_filter(session, query, sample_filter, require_meter=True):
     """Return a query dictionary based on the settings in the filter.
 
     :param filter: SampleFilter instance
@@ -134,7 +173,8 @@ def make_query_from_filter(query, sample_filter, require_meter=True):
         query = query.filter_by(resource_id=sample_filter.resource)
 
     if sample_filter.metaquery:
-        raise NotImplementedError(_('metaquery not implemented'))
+        query = apply_metaquery_filter(session, query,
+                                       sample_filter.metaquery)
 
     return query
 
@@ -229,6 +269,21 @@ class Connection(base.Connection):
             meter.message_signature = data['message_signature']
             meter.message_id = data['message_id']
 
+            if rmetadata:
+                if isinstance(rmetadata, dict):
+                    for key, v in utils.dict_to_keyval(rmetadata):
+                        try:
+                            _model = META_TYPE_MAP[type(v)]
+                        except KeyError:
+                            LOG.warn(_("Unknown metadata type. Key (%s) will "
+                                       "not be queryable."), key)
+                        else:
+                            session.add(_model(id=meter.id,
+                                               meta_key=key,
+                                               value=v))
+
+            session.flush()
+
     @staticmethod
     def clear_expired_metering_data(ttl):
         """Clear expired data from the backend storage system according to the
@@ -306,8 +361,6 @@ class Connection(base.Connection):
         # just fail.
         if pagination:
             raise NotImplementedError(_('Pagination not implemented'))
-        if metaquery:
-            raise NotImplementedError(_('metaquery not implemented'))
 
         # (thomasm) We need to get the max timestamp first, since that's the
         # most accurate. We also need to filter down in the subquery to
@@ -331,6 +384,11 @@ class Connection(base.Connection):
             ts_subquery = ts_subquery.filter(
                 Meter.sources.any(id=source))
 
+        if metaquery:
+            ts_subquery = apply_metaquery_filter(session,
+                                                 ts_subquery,
+                                                 metaquery)
+
         # Here we limit the samples being used to a specific time period,
         # if requested.
         if start_timestamp:
@@ -397,8 +455,6 @@ class Connection(base.Connection):
 
         if pagination:
             raise NotImplementedError(_('Pagination not implemented'))
-        if metaquery:
-            raise NotImplementedError(_('metaquery not implemented'))
 
         session = sqlalchemy_session.get_session()
 
@@ -422,6 +478,11 @@ class Connection(base.Connection):
         query_meter = session.query(Meter).\
             join(subquery_meter, Meter.id == subquery_meter.c.id)
 
+        if metaquery:
+            query_meter = apply_metaquery_filter(session,
+                                                 query_meter,
+                                                 metaquery)
+
         alias_meter = aliased(Meter, query_meter.subquery())
         query = session.query(Resource, alias_meter).join(
             alias_meter, Resource.id == alias_meter.resource_id)
@@ -457,7 +518,7 @@ class Connection(base.Connection):
 
         session = sqlalchemy_session.get_session()
         query = session.query(Meter)
-        query = make_query_from_filter(query, sample_filter,
+        query = make_query_from_filter(session, query, sample_filter,
                                        require_meter=False)
         if limit:
             query = query.limit(limit)
@@ -509,7 +570,7 @@ class Connection(base.Connection):
         if groupby:
             query = query.group_by(*group_attributes)
 
-        return make_query_from_filter(query, sample_filter)
+        return make_query_from_filter(session, query, sample_filter)
 
     @staticmethod
     def _stats_result_to_model(result, period, period_start,
diff --git a/ceilometer/storage/sqlalchemy/migrate_repo/versions/020_add_metadata_tables.py b/ceilometer/storage/sqlalchemy/migrate_repo/versions/020_add_metadata_tables.py
new file mode 100644
index 0000000..085cd6b
--- /dev/null
+++ b/ceilometer/storage/sqlalchemy/migrate_repo/versions/020_add_metadata_tables.py
@@ -0,0 +1,78 @@
+#
+# Copyright 2013 OpenStack Foundation
+# All Rights Reserved.
+#
+# 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.
+import json
+
+from sqlalchemy import Boolean
+from sqlalchemy import Column
+from sqlalchemy import Float
+from sqlalchemy import ForeignKey
+from sqlalchemy import Integer
+from sqlalchemy import MetaData
+from sqlalchemy import String
+from sqlalchemy import Table
+from sqlalchemy import Text
+from sqlalchemy.sql import select
+
+from ceilometer import utils
+
+tables = [('metadata_text', Text, True),
+          ('metadata_bool', Boolean, False),
+          ('metadata_int', Integer, False),
+          ('metadata_float', Float, False)]
+
+
+def upgrade(migrate_engine):
+    meta = MetaData(bind=migrate_engine)
+    meter = Table('meter', meta, autoload=True)
+    meta_tables = {}
+    for t_name, t_type, t_nullable in tables:
+        meta_tables[t_name] = Table(
+            t_name, meta,
+            Column('id', Integer, ForeignKey('meter.id'), primary_key=True),
+            Column('meta_key', String(255), index=True, primary_key=True),
+            Column('value', t_type, nullable=t_nullable),
+            mysql_engine='InnoDB',
+            mysql_charset='utf8',
+        )
+        meta_tables[t_name].create()
+
+    for row in select([meter]).execute():
+        meter_id = row['id']
+        rmeta = json.loads(row['resource_metadata'])
+        for key, v in utils.dict_to_keyval(rmeta):
+            if isinstance(v, basestring) or v is None:
+                meta_tables['metadata_text'].insert().values(id=meter_id,
+                                                             meta_key=key,
+                                                             value=v)
+            elif isinstance(v, bool):
+                meta_tables['metadata_bool'].insert().values(id=meter_id,
+                                                             meta_key=key,
+                                                             value=v)
+            elif isinstance(v, (int, long)):
+                meta_tables['metadata_int'].insert().values(id=meter_id,
+                                                            meta_key=key,
+                                                            value=v)
+            elif isinstance(v, float):
+                meta_tables['metadata_float'].insert().values(id=meter_id,
+                                                              meta_key=key,
+                                                              value=v)
+
+
+def downgrade(migrate_engine):
+    meta = MetaData(bind=migrate_engine)
+    for t in tables:
+        table = Table(t[0], meta, autoload=True)
+        table.drop()
diff --git a/ceilometer/storage/sqlalchemy/models.py b/ceilometer/storage/sqlalchemy/models.py
index 45f98cb..8f890b3 100644
--- a/ceilometer/storage/sqlalchemy/models.py
+++ b/ceilometer/storage/sqlalchemy/models.py
@@ -141,6 +141,54 @@ class Source(Base):
     id = Column(String(255), primary_key=True)
 
 
+class MetaText(Base):
+    """Metering text metadata."""
+
+    __tablename__ = 'metadata_text'
+    __table_args__ = (
+        Index('ix_meta_text_key', 'meta_key'),
+    )
+    id = Column(Integer, ForeignKey('meter.id'), primary_key=True)
+    meta_key = Column(String(255), primary_key=True)
+    value = Column(Text)
+
+
+class MetaBool(Base):
+    """Metering boolean metadata."""
+
+    __tablename__ = 'metadata_bool'
+    __table_args__ = (
+        Index('ix_meta_bool_key', 'meta_key'),
+    )
+    id = Column(Integer, ForeignKey('meter.id'), primary_key=True)
+    meta_key = Column(String(255), primary_key=True)
+    value = Column(Boolean)
+
+
+class MetaInt(Base):
+    """Metering integer metadata."""
+
+    __tablename__ = 'metadata_int'
+    __table_args__ = (
+        Index('ix_meta_int_key', 'meta_key'),
+    )
+    id = Column(Integer, ForeignKey('meter.id'), primary_key=True)
+    meta_key = Column(String(255), primary_key=True)
+    value = Column(Integer, default=False)
+
+
+class MetaFloat(Base):
+    """Metering float metadata."""
+
+    __tablename__ = 'metadata_float'
+    __table_args__ = (
+        Index('ix_meta_float_key', 'meta_key'),
+    )
+    id = Column(Integer, ForeignKey('meter.id'), primary_key=True)
+    meta_key = Column(String(255), primary_key=True)
+    value = Column(Float, default=False)
+
+
 class Meter(Base):
     """Metering data."""
 
diff --git a/ceilometer/utils.py b/ceilometer/utils.py
index 07faf29..0bc7dfc 100644
--- a/ceilometer/utils.py
+++ b/ceilometer/utils.py
@@ -90,3 +90,27 @@ def stringify_timestamps(data):
     isa_timestamp = lambda v: isinstance(v, datetime.datetime)
     return dict((k, v.isoformat() if isa_timestamp(v) else v)
                 for (k, v) in data.iteritems())
+
+
+def dict_to_keyval(value, key_base=None):
+    """Expand a given dict to its corresponding key-value pairs.
+
+    Generated keys are fully qualified, delimited using dot notation.
+    ie. key = 'key.child_key.grandchild_key[0]'
+    """
+    val_iter, key_func = None, None
+    if isinstance(value, dict):
+        val_iter = value.iteritems()
+        key_func = lambda k: key_base + '.' + k if key_base else k
+    elif isinstance(value, (tuple, list)):
+        val_iter = enumerate(value)
+        key_func = lambda k: key_base + '[%d]' % k
+
+    if val_iter:
+        for k, v in val_iter:
+            key_gen = key_func(k)
+            if isinstance(v, dict) or isinstance(v, (tuple, list)):
+                for key_gen, v in dict_to_keyval(v, key_gen):
+                    yield key_gen, v
+            else:
+                yield key_gen, v
diff --git a/doc/source/install/dbreco.rst b/doc/source/install/dbreco.rst
index fe60329..249cdc7 100644
--- a/doc/source/install/dbreco.rst
+++ b/doc/source/install/dbreco.rst
@@ -43,8 +43,8 @@ The following is a table indicating the status of each database drivers:
 Driver             API querying                  API statistics      Alarms
 ================== ============================= =================== ======
 MongoDB            Yes                           Yes                 Yes
-MySQL              Yes, except metadata querying Yes                 Yes
-PostgreSQL         Yes, except metadata querying Yes                 Yes
+MySQL              Yes                           Yes                 Yes
+PostgreSQL         Yes                           Yes                 Yes
 HBase              Yes                           Yes, except groupby No
 DB2                Yes                           Yes                 No
 ================== ============================= =================== ======
diff --git a/tests/api/v2/test_list_meters_scenarios.py b/tests/api/v2/test_list_meters_scenarios.py
index fe2c5b7..3381e15 100644
--- a/tests/api/v2/test_list_meters_scenarios.py
+++ b/tests/api/v2/test_list_meters_scenarios.py
@@ -252,6 +252,7 @@ class TestListMeters(FunctionalTest,
                          set(['meter.mine']))
         self.assertEqual(set(r['resource_metadata']['is_public'] for r
                              in data), set(['False']))
+        # FIXME(gordc): verify no false positive (Bug#1236496)
 
     def test_list_meters_query_string_metadata(self):
         data = self.get_json('/meters/meter.test',
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 90e06d6..ec60542 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -84,3 +84,19 @@ class TestUtils(tests_base.TestCase):
 
     def test_decimal_to_dt_with_none_parameter(self):
         self.assertEqual(utils.decimal_to_dt(None), None)
+
+    def test_dict_to_kv(self):
+        data = {'a': 'A',
+                'b': 'B',
+                'nested': {'a': 'A',
+                           'b': 'B',
+                           },
+                'nested2': [{'c': 'A'}, {'c': 'B'}]
+                }
+        pairs = list(utils.dict_to_keyval(data))
+        self.assertEqual(pairs, [('a', 'A'),
+                                 ('b', 'B'),
+                                 ('nested2[0].c', 'A'),
+                                 ('nested2[1].c', 'B'),
+                                 ('nested.a', 'A'),
+                                 ('nested.b', 'B')])
-- 
1.8.4.1

openSUSE Build Service is sponsored by