diff --git a/Makefile.am b/Makefile.am index 8f39bd31ad4c4f0bad0d0fc680c372d620ca07e3..b51685a30f80f879d07cee29bfbd492fb89c1f20 100644 --- a/Makefile.am +++ b/Makefile.am @@ -138,6 +138,7 @@ pkgpython_PYTHON = \ lib/netutils.py \ lib/objects.py \ lib/opcodes.py \ + lib/query.py \ lib/rpc.py \ lib/runtime.py \ lib/serializer.py \ @@ -442,6 +443,7 @@ python_tests = \ test/ganeti.netutils_unittest.py \ test/ganeti.objects_unittest.py \ test/ganeti.opcodes_unittest.py \ + test/ganeti.query_unittest.py \ test/ganeti.rapi.client_unittest.py \ test/ganeti.rapi.resources_unittest.py \ test/ganeti.rapi.rlib2_unittest.py \ diff --git a/lib/query.py b/lib/query.py new file mode 100644 index 0000000000000000000000000000000000000000..9be8c545290b0d7186fc4fc220fa97ff406e588e --- /dev/null +++ b/lib/query.py @@ -0,0 +1,233 @@ +# +# + +# Copyright (C) 2010 Google Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. + + +"""Module for query operations""" + +import operator +import re + +from ganeti import constants +from ganeti import errors +from ganeti import utils +from ganeti import compat +from ganeti import objects +from ganeti import ht + + +FIELD_NAME_RE = re.compile(r"^[a-z0-9/._]+$") +TITLE_RE = re.compile(r"^[^\s]+$") + +#: Verification function for each field type +_VERIFY_FN = { + constants.QFT_UNKNOWN: ht.TNone, + constants.QFT_TEXT: ht.TString, + constants.QFT_BOOL: ht.TBool, + constants.QFT_NUMBER: ht.TInt, + constants.QFT_UNIT: ht.TInt, + constants.QFT_TIMESTAMP: ht.TOr(ht.TInt, ht.TFloat), + constants.QFT_OTHER: lambda _: True, + } + + +def _GetUnknownField(ctx, item): # pylint: disable-msg=W0613 + """Gets the contents of an unknown field. + + """ + return (constants.QRFS_UNKNOWN, None) + + +def _GetQueryFields(fielddefs, selected): + """Calculates the internal list of selected fields. + + Unknown fields are returned as L{constants.QFT_UNKNOWN}. + + @type fielddefs: dict + @param fielddefs: Field definitions + @type selected: list of strings + @param selected: List of selected fields + + """ + result = [] + + for name in selected: + try: + fdef = fielddefs[name] + except KeyError: + fdef = (_MakeField(name, name, constants.QFT_UNKNOWN), + None, _GetUnknownField) + + assert len(fdef) == 3 + + result.append(fdef) + + return result + + +def GetAllFields(fielddefs): + """Extract L{objects.QueryFieldDefinition} from field definitions. + + @rtype: list of L{objects.QueryFieldDefinition} + + """ + return [fdef for (fdef, _, _) in fielddefs] + + +class Query: + def __init__(self, fieldlist, selected): + """Initializes this class. + + The field definition is a dictionary with the field's name as a key and a + tuple containing, in order, the field definition object + (L{objects.QueryFieldDefinition}, the data kind to help calling code + collect data and a retrieval function. The retrieval function is called + with two parameters, in order, the data container and the item in container + (see L{Query.Query}). + + Users of this class can call L{RequestedData} before preparing the data + container to determine what data is needed. + + @type fieldlist: dictionary + @param fieldlist: Field definitions + @type selected: list of strings + @param selected: List of selected fields + + """ + self._fields = _GetQueryFields(fieldlist, selected) + + def RequestedData(self): + """Gets requested kinds of data. + + @rtype: frozenset + + """ + return frozenset(datakind + for (_, datakind, _) in self._fields + if datakind is not None) + + def GetFields(self): + """Returns the list of fields for this query. + + Includes unknown fields. + + @rtype: List of L{objects.QueryFieldDefinition} + + """ + return GetAllFields(self._fields) + + def Query(self, ctx): + """Execute a query. + + @param ctx: Data container passed to field retrieval functions, must + support iteration using C{__iter__} + + """ + result = [[fn(ctx, item) for (_, _, fn) in self._fields] + for item in ctx] + + # Verify result + if __debug__: + for (idx, row) in enumerate(result): + assert _VerifyResultRow(self._fields, row), \ + ("Inconsistent result for fields %s in row %s: %r" % + (self._fields, idx, row)) + + return result + + def OldStyleQuery(self, ctx): + """Query with "old" query result format. + + See L{Query.Query} for arguments. + + """ + unknown = set(fdef.name + for (fdef, _, _) in self._fields + if fdef.kind == constants.QFT_UNKNOWN) + if unknown: + raise errors.OpPrereqError("Unknown output fields selected: %s" % + (utils.CommaJoin(unknown), ), + errors.ECODE_INVAL) + + return [[value for (_, value) in row] + for row in self.Query(ctx)] + + +def _VerifyResultRow(fields, row): + """Verifies the contents of a query result row. + + @type fields: list + @param fields: Field definitions for result + @type row: list of tuples + @param row: Row data + + """ + return (len(row) == len(fields) and + compat.all((status == constants.QRFS_NORMAL and + _VERIFY_FN[fdef.kind](value)) or + # Value for an abnormal status must be None + (status != constants.QRFS_NORMAL and value is None) + for ((status, value), (fdef, _, _)) in zip(row, fields))) + + +def _PrepareFieldList(fields): + """Prepares field list for use by L{Query}. + + Converts the list to a dictionary and does some verification. + + @type fields: list of tuples; (L{objects.QueryFieldDefinition}, data kind, + retrieval function) + @param fields: List of fields + @rtype: dict + @return: Field dictionary for L{Query} + + """ + assert len(set(fdef.title.lower() + for (fdef, _, _) in fields)) == len(fields), \ + "Duplicate title found" + + result = {} + + for field in fields: + (fdef, _, fn) = field + + assert fdef.name and fdef.title, "Name and title are required" + assert FIELD_NAME_RE.match(fdef.name) + assert TITLE_RE.match(fdef.title) + assert callable(fn) + assert fdef.name not in result, "Duplicate field name found" + + result[fdef.name] = field + + assert len(result) == len(fields) + assert compat.all(name == fdef.name + for (name, (fdef, _, _)) in result.items()) + + return result + + +def _MakeField(name, title, kind): + """Wrapper for creating L{objects.QueryFieldDefinition} instances. + + @param name: Field name as a regular expression + @param title: Human-readable title + @param kind: Field type + + """ + return objects.QueryFieldDefinition(name=name, title=title, kind=kind) diff --git a/test/ganeti.query_unittest.py b/test/ganeti.query_unittest.py new file mode 100755 index 0000000000000000000000000000000000000000..098645756095e8932fe75a5dc8722ee704debd95 --- /dev/null +++ b/test/ganeti.query_unittest.py @@ -0,0 +1,258 @@ +#!/usr/bin/python +# + +# Copyright (C) 2010 Google Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. + + +"""Script for testing ganeti.query""" + +import re +import unittest + +from ganeti import constants +from ganeti import utils +from ganeti import compat +from ganeti import errors +from ganeti import query +from ganeti import objects + +import testutils + + +class TestConstants(unittest.TestCase): + def test(self): + self.assertEqual(set(query._VERIFY_FN.keys()), + constants.QFT_ALL) + + +class _QueryData: + def __init__(self, data, **kwargs): + self.data = data + + for name, value in kwargs.items(): + setattr(self, name, value) + + def __iter__(self): + return iter(self.data) + + +def _GetDiskSize(nr, ctx, item): + disks = item["disks"] + try: + return (constants.QRFS_NORMAL, disks[nr]) + except IndexError: + return (constants.QRFS_UNAVAIL, None) + + +class TestQuery(unittest.TestCase): + def test(self): + (STATIC, DISK) = range(10, 12) + + fielddef = query._PrepareFieldList([ + (query._MakeField("name", "Name", constants.QFT_TEXT), + STATIC, lambda ctx, item: (constants.QRFS_NORMAL, item["name"])), + (query._MakeField("master", "Master", constants.QFT_BOOL), + STATIC, lambda ctx, item: (constants.QRFS_NORMAL, + ctx.mastername == item["name"])), + ] + + [(query._MakeField("disk%s.size" % i, "DiskSize%s" % i, + constants.QFT_UNIT), + DISK, compat.partial(_GetDiskSize, i)) + for i in range(4)]) + + q = query.Query(fielddef, ["name"]) + self.assertEqual(q.RequestedData(), set([STATIC])) + self.assertEqual(len(q._fields), 1) + self.assertEqual(len(q.GetFields()), 1) + self.assertEqual(q.GetFields()[0].ToDict(), + objects.QueryFieldDefinition(name="name", + title="Name", + kind=constants.QFT_TEXT).ToDict()) + + # Create data only once query has been prepared + data = [ + { "name": "node1", "disks": [0, 1, 2], }, + { "name": "node2", "disks": [3, 4], }, + { "name": "node3", "disks": [5, 6, 7], }, + ] + + self.assertEqual(q.Query(_QueryData(data, mastername="node3")), + [[(constants.QRFS_NORMAL, "node1")], + [(constants.QRFS_NORMAL, "node2")], + [(constants.QRFS_NORMAL, "node3")]]) + self.assertEqual(q.OldStyleQuery(_QueryData(data, mastername="node3")), + [["node1"], ["node2"], ["node3"]]) + + q = query.Query(fielddef, ["name", "master"]) + self.assertEqual(q.RequestedData(), set([STATIC])) + self.assertEqual(len(q._fields), 2) + self.assertEqual(q.Query(_QueryData(data, mastername="node3")), + [[(constants.QRFS_NORMAL, "node1"), + (constants.QRFS_NORMAL, False)], + [(constants.QRFS_NORMAL, "node2"), + (constants.QRFS_NORMAL, False)], + [(constants.QRFS_NORMAL, "node3"), + (constants.QRFS_NORMAL, True)], + ]) + + q = query.Query(fielddef, ["name", "master", "disk0.size"]) + self.assertEqual(q.RequestedData(), set([STATIC, DISK])) + self.assertEqual(len(q._fields), 3) + self.assertEqual(q.Query(_QueryData(data, mastername="node2")), + [[(constants.QRFS_NORMAL, "node1"), + (constants.QRFS_NORMAL, False), + (constants.QRFS_NORMAL, 0)], + [(constants.QRFS_NORMAL, "node2"), + (constants.QRFS_NORMAL, True), + (constants.QRFS_NORMAL, 3)], + [(constants.QRFS_NORMAL, "node3"), + (constants.QRFS_NORMAL, False), + (constants.QRFS_NORMAL, 5)], + ]) + + # With unknown column + q = query.Query(fielddef, ["disk2.size", "disk1.size", "disk99.size", + "disk0.size"]) + self.assertEqual(q.RequestedData(), set([DISK])) + self.assertEqual(len(q._fields), 4) + self.assertEqual(q.Query(_QueryData(data, mastername="node2")), + [[(constants.QRFS_NORMAL, 2), + (constants.QRFS_NORMAL, 1), + (constants.QRFS_UNKNOWN, None), + (constants.QRFS_NORMAL, 0)], + [(constants.QRFS_UNAVAIL, None), + (constants.QRFS_NORMAL, 4), + (constants.QRFS_UNKNOWN, None), + (constants.QRFS_NORMAL, 3)], + [(constants.QRFS_NORMAL, 7), + (constants.QRFS_NORMAL, 6), + (constants.QRFS_UNKNOWN, None), + (constants.QRFS_NORMAL, 5)], + ]) + self.assertRaises(errors.OpPrereqError, q.OldStyleQuery, + _QueryData(data, mastername="node2")) + self.assertEqual([fdef.ToDict() for fdef in q.GetFields()], [ + { "name": "disk2.size", "title": "DiskSize2", + "kind": constants.QFT_UNIT, }, + { "name": "disk1.size", "title": "DiskSize1", + "kind": constants.QFT_UNIT, }, + { "name": "disk99.size", "title": "disk99.size", + "kind": constants.QFT_UNKNOWN, }, + { "name": "disk0.size", "title": "DiskSize0", + "kind": constants.QFT_UNIT, }, + ]) + + # Empty query + q = query.Query(fielddef, []) + self.assertEqual(q.RequestedData(), set([])) + self.assertEqual(len(q._fields), 0) + self.assertEqual(q.Query(_QueryData(data, mastername="node2")), + [[], [], []]) + self.assertEqual(q.OldStyleQuery(_QueryData(data, mastername="node2")), + [[], [], []]) + self.assertEqual(q.GetFields(), []) + + def testPrepareFieldList(self): + # Duplicate titles + for (a, b) in [("name", "name"), ("NAME", "name")]: + self.assertRaises(AssertionError, query._PrepareFieldList, [ + (query._MakeField("name", b, constants.QFT_TEXT), None, + lambda *args: None), + (query._MakeField("other", a, constants.QFT_TEXT), None, + lambda *args: None), + ]) + + # Non-lowercase names + self.assertRaises(AssertionError, query._PrepareFieldList, [ + (query._MakeField("NAME", "Name", constants.QFT_TEXT), None, + lambda *args: None), + ]) + self.assertRaises(AssertionError, query._PrepareFieldList, [ + (query._MakeField("Name", "Name", constants.QFT_TEXT), None, + lambda *args: None), + ]) + + # Empty name + self.assertRaises(AssertionError, query._PrepareFieldList, [ + (query._MakeField("", "Name", constants.QFT_TEXT), None, + lambda *args: None), + ]) + + # Empty title + self.assertRaises(AssertionError, query._PrepareFieldList, [ + (query._MakeField("name", "", constants.QFT_TEXT), None, + lambda *args: None), + ]) + + # Whitespace in title + self.assertRaises(AssertionError, query._PrepareFieldList, [ + (query._MakeField("name", "Co lu mn", constants.QFT_TEXT), None, + lambda *args: None), + ]) + + # No callable function + self.assertRaises(AssertionError, query._PrepareFieldList, [ + (query._MakeField("name", "Name", constants.QFT_TEXT), None, None), + ]) + + def testUnknown(self): + fielddef = query._PrepareFieldList([ + (query._MakeField("name", "Name", constants.QFT_TEXT), + None, lambda _, item: (constants.QRFS_NORMAL, "name%s" % item)), + (query._MakeField("other0", "Other0", constants.QFT_TIMESTAMP), + None, lambda *args: (constants.QRFS_NORMAL, 1234)), + (query._MakeField("nodata", "NoData", constants.QFT_NUMBER), + None, lambda *args: (constants.QRFS_NODATA, None)), + (query._MakeField("unavail", "Unavail", constants.QFT_BOOL), + None, lambda *args: (constants.QRFS_UNAVAIL, None)), + ]) + + for selected in [["foo"], ["Hello", "World"], + ["name1", "other", "foo"]]: + q = query.Query(fielddef, selected) + self.assertEqual(len(q._fields), len(selected)) + self.assert_(compat.all(len(row) == len(selected) + for row in q.Query(_QueryData(range(1, 10))))) + self.assertEqual(q.Query(_QueryData(range(1, 10))), + [[(constants.QRFS_UNKNOWN, None)] * len(selected) + for i in range(1, 10)]) + self.assertEqual([fdef.ToDict() for fdef in q.GetFields()], + [{ "name": name, "title": name, + "kind": constants.QFT_UNKNOWN, } + for name in selected]) + + q = query.Query(fielddef, ["name", "other0", "nodata", "unavail"]) + self.assertEqual(len(q._fields), 4) + self.assertEqual(q.OldStyleQuery(_QueryData(range(1, 10))), [ + ["name%s" % i, 1234, None, None] + for i in range(1, 10) + ]) + + q = query.Query(fielddef, ["name", "other0", "nodata", "unavail", "unk"]) + self.assertEqual(len(q._fields), 5) + self.assertEqual(q.Query(_QueryData(range(1, 10))), + [[(constants.QRFS_NORMAL, "name%s" % i), + (constants.QRFS_NORMAL, 1234), + (constants.QRFS_NODATA, None), + (constants.QRFS_UNAVAIL, None), + (constants.QRFS_UNKNOWN, None)] + for i in range(1, 10)]) + + +if __name__ == "__main__": + testutils.GanetiTestProgram()