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/types.py b/oslo_db/sqlalchemy/types.py index a6f8acb..f74f909 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,20 @@ 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. + + """ + + impl = Integer + + def process_bind_param(self, value, dialect): + return int(value) diff --git a/oslo_db/tests/sqlalchemy/test_models.py b/oslo_db/tests/sqlalchemy/test_models.py index 60e8c55..fc12b0b 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,50 @@ 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()