summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.rst9
-rw-r--r--oslo_db/sqlalchemy/models.py6
-rw-r--r--oslo_db/sqlalchemy/provision.py2
-rw-r--r--oslo_db/sqlalchemy/test_fixtures.py6
-rw-r--r--oslo_db/sqlalchemy/types.py30
-rw-r--r--oslo_db/tests/sqlalchemy/test_fixtures.py36
-rw-r--r--oslo_db/tests/sqlalchemy/test_models.py60
7 files changed, 131 insertions, 18 deletions
diff --git a/README.rst b/README.rst
index ee40d27..64500c0 100644
--- a/README.rst
+++ b/README.rst
@@ -1,3 +1,12 @@
+========================
+Team and repository tags
+========================
+
+.. image:: http://governance.openstack.org/badges/oslo.db.svg
+ :target: http://governance.openstack.org/reference/tags/index.html
+
+.. Change things from this point on
+
===============================================
oslo.db -- OpenStack Database Pattern Library
===============================================
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)