From 3a17d826a5b356f83590bb0a1cb9b93f281abd74 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Thu, 30 Jul 2015 15:42:28 +0200 Subject: Add JSON-encoded types for sqlalchemy This code is imported from Ironic with new tests, so that it can be reused in other projects (I need it for ironic-inspector). Some small enhancements were made to the imported code: * Non-capitalized JSON word in names * Base type can be used on its own Change-Id: Ic991f34c5b5f091d8627643367cdaa73ad2b1236 --- oslo_db/sqlalchemy/types.py | 62 ++++++++++++++++++++++++ oslo_db/tests/sqlalchemy/test_types.py | 86 ++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 oslo_db/sqlalchemy/types.py create mode 100644 oslo_db/tests/sqlalchemy/test_types.py diff --git a/oslo_db/sqlalchemy/types.py b/oslo_db/sqlalchemy/types.py new file mode 100644 index 0000000..477ed3f --- /dev/null +++ b/oslo_db/sqlalchemy/types.py @@ -0,0 +1,62 @@ +# 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.types import TypeDecorator, Text + + +class JsonEncodedType(TypeDecorator): + """Base column type for data serialized as JSON-encoded string in db.""" + type = None + impl = Text + + def process_bind_param(self, value, dialect): + if value is None: + if self.type is not None: + # Save default value according to current type to keep the + # interface consistent. + value = self.type() + elif self.type is not None and not isinstance(value, self.type): + raise TypeError("%s supposes to store %s objects, but %s given" + % (self.__class__.__name__, + self.type.__name__, + type(value).__name__)) + serialized_value = json.dumps(value) + return serialized_value + + def process_result_value(self, value, dialect): + if value is not None: + value = json.loads(value) + return value + + +class JsonEncodedDict(JsonEncodedType): + """Represents dict serialized as json-encoded string in db. + + Note that this type does NOT track mutations. If you want to update it, you + have to assign existing value to a temporary variable, update, then assign + back. See this page for more robust work around: + http://docs.sqlalchemy.org/en/rel_1_0/orm/extensions/mutable.html + """ + type = dict + + +class JsonEncodedList(JsonEncodedType): + """Represents list serialized as json-encoded string in db. + + Note that this type does NOT track mutations. If you want to update it, you + have to assign existing value to a temporary variable, update, then assign + back. See this page for more robust work around: + http://docs.sqlalchemy.org/en/rel_1_0/orm/extensions/mutable.html + """ + type = list diff --git a/oslo_db/tests/sqlalchemy/test_types.py b/oslo_db/tests/sqlalchemy/test_types.py new file mode 100644 index 0000000..c8abbe1 --- /dev/null +++ b/oslo_db/tests/sqlalchemy/test_types.py @@ -0,0 +1,86 @@ +# 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. + +"""Tests for JSON SQLAlchemy types.""" + +from sqlalchemy import Column, Integer +from sqlalchemy.ext.declarative import declarative_base + +from oslo_db import exception as db_exc +from oslo_db.sqlalchemy import models +from oslo_db.sqlalchemy import test_base +from oslo_db.sqlalchemy import types + + +BASE = declarative_base() + + +class JsonTable(BASE, models.ModelBase): + __tablename__ = 'test_json_types' + id = Column(Integer, primary_key=True) + jdict = Column(types.JsonEncodedDict) + jlist = Column(types.JsonEncodedList) + json = Column(types.JsonEncodedType) + + +class JsonTypesTestCase(test_base.DbTestCase): + def setUp(self): + super(JsonTypesTestCase, self).setUp() + JsonTable.__table__.create(self.engine) + self.addCleanup(JsonTable.__table__.drop, self.engine) + self.session = self.sessionmaker() + self.addCleanup(self.session.close) + + def test_default_value(self): + with self.session.begin(): + JsonTable(id=1).save(self.session) + obj = self.session.query(JsonTable).filter_by(id=1).one() + self.assertEqual([], obj.jlist) + self.assertEqual({}, obj.jdict) + self.assertIsNone(obj.json) + + def test_dict(self): + test = {'a': 42, 'b': [1, 2, 3]} + with self.session.begin(): + JsonTable(id=1, jdict=test).save(self.session) + obj = self.session.query(JsonTable).filter_by(id=1).one() + self.assertEqual(test, obj.jdict) + + def test_list(self): + test = [1, True, "hello", {}] + with self.session.begin(): + JsonTable(id=1, jlist=test).save(self.session) + obj = self.session.query(JsonTable).filter_by(id=1).one() + self.assertEqual(test, obj.jlist) + + def test_dict_type_check(self): + self.assertRaises(db_exc.DBError, + JsonTable(id=1, jdict=[]).save, self.session) + + def test_list_type_check(self): + self.assertRaises(db_exc.DBError, + JsonTable(id=1, jlist={}).save, self.session) + + def test_generic(self): + tested = [ + "string", + 42, + True, + None, + [1, 2, 3], + {'a': 'b'} + ] + for i, test in enumerate(tested): + with self.session.begin(): + JsonTable(id=i, json=test).save(self.session) + obj = self.session.query(JsonTable).filter_by(id=i).one() + self.assertEqual(test, obj.json) -- cgit v1.2.1