SoftDeleteMixin: coerce deleted param to be an integer

PostgreSQL is very strict about types and won't perform an automatic
type cast when trying to pass e.g. a boolean `false` value to be
INSERT'ed or UPDATE'ed.

Coerce the value of deleted bound parameter to always be an integer
in the DB layer by adding a custom SQLAlchemy integer type decorator
to make PostgreSQL happy, should someone pass a boolean value for
deleted column (it was meant to be of boolean type, but is integer
instead to allow for Nova use case of shadow tables and compound
unique constraints on something + deleted).

Closes-Bug: #1644513

Change-Id: I13c6233cfeb611b1b106eedfc9b57d2af313c46b
This commit is contained in:
Roman Podoliaka 2016-11-28 13:16:17 +02:00
parent 34f9a3ac7a
commit 8be136528e
3 changed files with 72 additions and 3 deletions

View File

@ -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."""

View File

@ -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)

View File

@ -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()