diff options
-rw-r--r-- | oslo_db/sqlalchemy/models.py | 6 | ||||
-rw-r--r-- | oslo_db/sqlalchemy/provision.py | 2 | ||||
-rw-r--r-- | oslo_db/sqlalchemy/test_fixtures.py | 6 | ||||
-rw-r--r-- | oslo_db/sqlalchemy/types.py | 30 | ||||
-rw-r--r-- | oslo_db/tests/sqlalchemy/test_fixtures.py | 36 | ||||
-rw-r--r-- | oslo_db/tests/sqlalchemy/test_models.py | 60 |
6 files changed, 122 insertions, 18 deletions
diff --git a/oslo_db/sqlalchemy/models.py b/oslo_db/sqlalchemy/models.py index 0c4e0de..0467971 100644 --- a/oslo_db/sqlalchemy/models.py +++ b/oslo_db/sqlalchemy/models.py @@ -23,10 +23,12 @@ SQLAlchemy models. import six from oslo_utils import timeutils -from sqlalchemy import Column, Integer +from sqlalchemy import Column from sqlalchemy import DateTime from sqlalchemy.orm import object_mapper +from oslo_db.sqlalchemy import types + class ModelBase(six.Iterator): """Base class for models.""" @@ -139,7 +141,7 @@ class TimestampMixin(object): class SoftDeleteMixin(object): deleted_at = Column(DateTime) - deleted = Column(Integer, default=0) + deleted = Column(types.SoftDeleteInteger, default=0) def soft_delete(self, session): """Mark this object as deleted.""" diff --git a/oslo_db/sqlalchemy/provision.py b/oslo_db/sqlalchemy/provision.py index a1e1d19..8f4a60c 100644 --- a/oslo_db/sqlalchemy/provision.py +++ b/oslo_db/sqlalchemy/provision.py @@ -111,7 +111,7 @@ class DatabaseResource(testresources.TestResourceManager): """ def __init__(self, database_type, _enginefacade=None, - provision_new_database=False, ad_hoc_url=None): + provision_new_database=True, ad_hoc_url=None): super(DatabaseResource, self).__init__() self.database_type = database_type self.provision_new_database = provision_new_database diff --git a/oslo_db/sqlalchemy/test_fixtures.py b/oslo_db/sqlalchemy/test_fixtures.py index fd954b3..9ee86c8 100644 --- a/oslo_db/sqlalchemy/test_fixtures.py +++ b/oslo_db/sqlalchemy/test_fixtures.py @@ -324,7 +324,8 @@ class SimpleDbFixture(BaseDbFixture): return self._dependency_resources["_db_%s" % self.ident] def _generate_database_resource(self, _enginefacade): - return provision.DatabaseResource(self.driver, _enginefacade) + return provision.DatabaseResource(self.driver, _enginefacade, + provision_new_database=False) def _setUp(self): super(SimpleDbFixture, self)._setUp() @@ -392,7 +393,8 @@ class AdHocDbFixture(SimpleDbFixture): def _generate_database_resource(self, _enginefacade): return provision.DatabaseResource( - self.driver, _enginefacade, ad_hoc_url=self.url) + self.driver, _enginefacade, ad_hoc_url=self.url, + provision_new_database=False) def _cleanup(self): self._teardown_resources() diff --git a/oslo_db/sqlalchemy/types.py b/oslo_db/sqlalchemy/types.py index a6f8acb..2dabe4c 100644 --- a/oslo_db/sqlalchemy/types.py +++ b/oslo_db/sqlalchemy/types.py @@ -12,7 +12,7 @@ import json -from sqlalchemy.types import TypeDecorator, Text +from sqlalchemy.types import Integer, TypeDecorator, Text from sqlalchemy.dialects import mysql @@ -73,3 +73,31 @@ class JsonEncodedList(JsonEncodedType): http://docs.sqlalchemy.org/en/rel_1_0/orm/extensions/mutable.html """ type = list + + +class SoftDeleteInteger(TypeDecorator): + """Coerce a bound param to be a proper integer before passing it to DBAPI. + + Some backends like PostgreSQL are very strict about types and do not + perform automatic type casts, e.g. when trying to INSERT a boolean value + like ``false`` into an integer column. Coercing of the bound param in DB + layer by the means of a custom SQLAlchemy type decorator makes sure we + always pass a proper integer value to a DBAPI implementation. + + This is not a general purpose boolean integer type as it specifically + allows for arbitrary positive integers outside of the boolean int range + (0, 1, False, True), so that it's possible to have compound unique + constraints over multiple columns including ``deleted`` (e.g. to + soft-delete flavors with the same name in Nova without triggering + a constraint violation): ``deleted`` is set to be equal to a PK + int value on deletion, 0 denotes a non-deleted row. + + """ + + impl = Integer + + def process_bind_param(self, value, dialect): + if value is None: + return None + else: + return int(value) diff --git a/oslo_db/tests/sqlalchemy/test_fixtures.py b/oslo_db/tests/sqlalchemy/test_fixtures.py index f256d30..b769e20 100644 --- a/oslo_db/tests/sqlalchemy/test_fixtures.py +++ b/oslo_db/tests/sqlalchemy/test_fixtures.py @@ -16,6 +16,7 @@ import testresources import testscenarios import unittest +from oslo_db import exception from oslo_db.sqlalchemy import enginefacade from oslo_db.sqlalchemy import provision from oslo_db.sqlalchemy import test_base as legacy_test_base @@ -204,19 +205,30 @@ class EnginefacadeIntegrationTest(oslo_test_base.BaseTestCase): class LegacyBaseClassTest(oslo_test_base.BaseTestCase): - def test_new_db_is_provisioned_by_default(self): - classes = [ - legacy_test_base.MySQLOpportunisticTestCase, + def test_new_db_is_provisioned_by_default_pg(self): + self._test_new_db_is_provisioned_by_default( legacy_test_base.PostgreSQLOpportunisticTestCase - ] - for base_cls in classes: - class SomeTest(base_cls): - def runTest(self): - pass - st = SomeTest() - - db_resource = dict(st.resources)['db'] - self.assertTrue(db_resource.provision_new_database) + ) + + def test_new_db_is_provisioned_by_default_mysql(self): + self._test_new_db_is_provisioned_by_default( + legacy_test_base.MySQLOpportunisticTestCase + ) + + def _test_new_db_is_provisioned_by_default(self, base_cls): + try: + provision.DatabaseResource(base_cls.FIXTURE.DRIVER) + except exception.BackendNotAvailable: + self.skip("Backend %s is not available" % + base_cls.FIXTURE.DRIVER) + + class SomeTest(base_cls): + def runTest(self): + pass + st = SomeTest() + + db_resource = dict(st.resources)['db'] + self.assertTrue(db_resource.provision_new_database) class TestLoadHook(unittest.TestCase): diff --git a/oslo_db/tests/sqlalchemy/test_models.py b/oslo_db/tests/sqlalchemy/test_models.py index 60e8c55..893d96f 100644 --- a/oslo_db/tests/sqlalchemy/test_models.py +++ b/oslo_db/tests/sqlalchemy/test_models.py @@ -14,10 +14,13 @@ # under the License. import collections +import datetime +import mock from oslotest import base as oslo_test from sqlalchemy import Column from sqlalchemy import Integer, String +from sqlalchemy import event from sqlalchemy.ext.declarative import declarative_base from oslo_db.sqlalchemy import models @@ -179,3 +182,60 @@ class TimestampMixinTest(oslo_test.BaseTestCase): for method in methods: self.assertTrue(hasattr(models.TimestampMixin, method), "Method %s() is not found" % method) + + +class SoftDeletedModel(BASE, models.ModelBase, models.SoftDeleteMixin): + __tablename__ = 'test_model_soft_deletes' + + id = Column('id', Integer, primary_key=True) + smth = Column('smth', String(255)) + + +class SoftDeleteMixinTest(test_base.DbTestCase): + def setUp(self): + super(SoftDeleteMixinTest, self).setUp() + + t = BASE.metadata.tables['test_model_soft_deletes'] + t.create(self.engine) + self.addCleanup(t.drop, self.engine) + + self.session = self.sessionmaker(autocommit=False) + self.addCleanup(self.session.close) + + @mock.patch('oslo_utils.timeutils.utcnow') + def test_soft_delete(self, mock_utcnow): + dt = datetime.datetime.utcnow().replace(microsecond=0) + mock_utcnow.return_value = dt + + m = SoftDeletedModel(id=123456, smth='test') + self.session.add(m) + self.session.commit() + self.assertEqual(0, m.deleted) + self.assertIs(None, m.deleted_at) + + m.soft_delete(self.session) + self.assertEqual(123456, m.deleted) + self.assertIs(dt, m.deleted_at) + + def test_soft_delete_coerce_deleted_to_integer(self): + def listener(conn, cur, stmt, params, context, executemany): + if 'insert' in stmt.lower(): # ignore SELECT 1 and BEGIN + self.assertNotIn('False', str(params)) + + event.listen(self.engine, 'before_cursor_execute', listener) + self.addCleanup(event.remove, + self.engine, 'before_cursor_execute', listener) + + m = SoftDeletedModel(id=1, smth='test', deleted=False) + self.session.add(m) + self.session.commit() + + def test_deleted_set_to_null(self): + m = SoftDeletedModel(id=123456, smth='test') + self.session.add(m) + self.session.commit() + + m.deleted = None + self.session.commit() + + self.assertIsNone(m.deleted) |