oslo.db/oslo_db/tests/sqlalchemy/test_update_match.py
Mike Bayer e0baed656e Implement generic update-on-match feature
This feature provides the query.update_on_match()
and query.update_returning_pk() methods, as well
as the manufacture_persistent_object(),
manufacture_entity_criteria(), and manufacture_criteria()
utility functions.

query.update_on_match() is used to UPDATE a row based on a variety
of criteria, and to then return a fully persistent object
state representing the row that was matched.  It
essentially intends to provide an UPDATE that is guaranteed
to have matched a specific row in the presence of potential
race conditions without using any locking, and to then
return a record of that row as if it had been SELECTed.

query.update_returning_pk() is a public method that
also serves as part of the implementation of
query.update_on_match(); this method delivers an UPDATE
statement such that the primary key of the single row
matched is returned; if zero or multiple rows are matched,
and error is raised.   To handle this, several backend-specific
strategies are provided, which are automatically selected
based on the best available.  The lowest strategy
performs a re-SELECT, but still assumes there's
a simple unique column to be queried on, as is currently the
use case in Nova (uuid is present).  On Postgresql, MySQL
and other databases besides SQLite and possibly DB2, more
atomic strategies are used.

Change-Id: I059f4ae6e72cfa6681a179314144214639f283ef
2015-03-05 14:06:59 +00:00

446 lines
14 KiB
Python

# 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.
from oslotest import base as oslo_test_base
from sqlalchemy.ext import declarative
from sqlalchemy import schema
from sqlalchemy import sql
from sqlalchemy import types as sqltypes
from oslo_db.sqlalchemy import test_base
from oslo_db.sqlalchemy import update_match
Base = declarative.declarative_base()
class MyModel(Base):
__tablename__ = 'my_table'
id = schema.Column(sqltypes.Integer, primary_key=True)
uuid = schema.Column(sqltypes.String(36), nullable=False, unique=True)
x = schema.Column(sqltypes.Integer)
y = schema.Column(sqltypes.String(40))
z = schema.Column(sqltypes.String(40))
class ManufactureCriteriaTest(oslo_test_base.BaseTestCase):
def test_instance_criteria_basic(self):
specimen = MyModel(
y='y1', z='z3',
uuid='136254d5-3869-408f-9da7-190e0072641a'
)
self.assertEqual(
"my_table.uuid = :uuid_1 AND my_table.y = :y_1 "
"AND my_table.z = :z_1",
str(update_match.manufacture_entity_criteria(specimen).compile())
)
def test_instance_criteria_basic_wnone(self):
specimen = MyModel(
y='y1', z=None,
uuid='136254d5-3869-408f-9da7-190e0072641a'
)
self.assertEqual(
"my_table.uuid = :uuid_1 AND my_table.y = :y_1 "
"AND my_table.z IS NULL",
str(update_match.manufacture_entity_criteria(specimen).compile())
)
def test_instance_criteria_tuples(self):
specimen = MyModel(
y='y1', z=('z1', 'z2'),
)
self.assertEqual(
"my_table.y = :y_1 AND my_table.z IN (:z_1, :z_2)",
str(update_match.manufacture_entity_criteria(specimen).compile())
)
def test_instance_criteria_tuples_wnone(self):
specimen = MyModel(
y='y1', z=('z1', 'z2', None),
)
self.assertEqual(
"my_table.y = :y_1 AND (my_table.z IS NULL OR "
"my_table.z IN (:z_1, :z_2))",
str(update_match.manufacture_entity_criteria(specimen).compile())
)
def test_instance_criteria_none_list(self):
specimen = MyModel(
y='y1', z=[None],
)
self.assertEqual(
"my_table.y = :y_1 AND my_table.z IS NULL",
str(update_match.manufacture_entity_criteria(specimen).compile())
)
class UpdateMatchTest(test_base.DbTestCase):
def setUp(self):
super(UpdateMatchTest, self).setUp()
Base.metadata.create_all(self.engine)
self.addCleanup(Base.metadata.drop_all, self.engine)
# self.engine.echo = 'debug'
self.session = self.sessionmaker(autocommit=False)
self.addCleanup(self.session.close)
self.session.add_all([
MyModel(
id=1,
uuid='23cb9224-9f8e-40fe-bd3c-e7577b7af37d',
x=5, y='y1', z='z1'),
MyModel(
id=2,
uuid='136254d5-3869-408f-9da7-190e0072641a',
x=6, y='y1', z='z2'),
MyModel(
id=3,
uuid='094eb162-d5df-494b-a458-a91a1b2d2c65',
x=7, y='y1', z='z1'),
MyModel(
id=4,
uuid='94659b3f-ea1f-4ffd-998d-93b28f7f5b70',
x=8, y='y2', z='z2'),
MyModel(
id=5,
uuid='bdf3893c-ee3c-40a0-bc79-960adb6cd1d4',
x=8, y='y2', z=None),
])
self.session.commit()
def _assert_row(self, pk, values):
row = self.session.execute(
sql.select([MyModel.__table__]).where(MyModel.__table__.c.id == pk)
).first()
values['id'] = pk
self.assertEqual(values, dict(row))
def test_update_specimen_successful(self):
uuid = '136254d5-3869-408f-9da7-190e0072641a'
specimen = MyModel(
y='y1', z='z2', uuid=uuid
)
result = self.session.query(MyModel).update_on_match(
specimen,
'uuid',
values={'x': 9, 'z': 'z3'}
)
self.assertEqual(uuid, result.uuid)
self.assertEqual(2, result.id)
self.assertEqual('z3', result.z)
self.assertIn(result, self.session)
self._assert_row(
2,
{
'uuid': '136254d5-3869-408f-9da7-190e0072641a',
'x': 9, 'y': 'y1', 'z': 'z3'
}
)
def test_update_specimen_include_only(self):
uuid = '136254d5-3869-408f-9da7-190e0072641a'
specimen = MyModel(
y='y9', z='z5', x=6, uuid=uuid
)
# Query the object first to test that we merge when the object is
# already cached in the session.
self.session.query(MyModel).filter(MyModel.uuid == uuid).one()
result = self.session.query(MyModel).update_on_match(
specimen,
'uuid',
values={'x': 9, 'z': 'z3'},
include_only=('x', )
)
self.assertEqual(uuid, result.uuid)
self.assertEqual(2, result.id)
self.assertEqual('z3', result.z)
self.assertIn(result, self.session)
self.assertNotIn(result, self.session.dirty)
self._assert_row(
2,
{
'uuid': '136254d5-3869-408f-9da7-190e0072641a',
'x': 9, 'y': 'y1', 'z': 'z3'
}
)
def test_update_specimen_no_rows(self):
specimen = MyModel(
y='y1', z='z3',
uuid='136254d5-3869-408f-9da7-190e0072641a'
)
exc = self.assertRaises(
update_match.NoRowsMatched,
self.session.query(MyModel).update_on_match,
specimen, 'uuid', values={'x': 9, 'z': 'z3'}
)
self.assertEqual("Zero rows matched for 3 attempts", exc.args[0])
def test_update_specimen_process_query_no_rows(self):
specimen = MyModel(
y='y1', z='z2',
uuid='136254d5-3869-408f-9da7-190e0072641a'
)
def process_query(query):
return query.filter_by(x=10)
exc = self.assertRaises(
update_match.NoRowsMatched,
self.session.query(MyModel).update_on_match,
specimen, 'uuid', values={'x': 9, 'z': 'z3'},
process_query=process_query
)
self.assertEqual("Zero rows matched for 3 attempts", exc.args[0])
def test_update_specimen_given_query_no_rows(self):
specimen = MyModel(
y='y1', z='z2',
uuid='136254d5-3869-408f-9da7-190e0072641a'
)
query = self.session.query(MyModel).filter_by(x=10)
exc = self.assertRaises(
update_match.NoRowsMatched,
query.update_on_match,
specimen, 'uuid', values={'x': 9, 'z': 'z3'},
)
self.assertEqual("Zero rows matched for 3 attempts", exc.args[0])
def test_update_specimen_multi_rows(self):
specimen = MyModel(
y='y1', z='z1',
)
exc = self.assertRaises(
update_match.MultiRowsMatched,
self.session.query(MyModel).update_on_match,
specimen, 'y', values={'x': 9, 'z': 'z3'}
)
self.assertEqual("2 rows matched; expected one", exc.args[0])
def test_update_specimen_query_mismatch_error(self):
specimen = MyModel(
y='y1'
)
q = self.session.query(MyModel.x, MyModel.y)
exc = self.assertRaises(
AssertionError,
q.update_on_match,
specimen, 'y', values={'x': 9, 'z': 'z3'},
)
self.assertEqual("Query does not match given specimen", exc.args[0])
def test_custom_handle_failure_raise_new(self):
class MyException(Exception):
pass
def handle_failure(query):
# ensure the query is usable
result = query.count()
self.assertEqual(0, result)
raise MyException("test: %d" % result)
specimen = MyModel(
y='y1', z='z3',
uuid='136254d5-3869-408f-9da7-190e0072641a'
)
exc = self.assertRaises(
MyException,
self.session.query(MyModel).update_on_match,
specimen, 'uuid', values={'x': 9, 'z': 'z3'},
handle_failure=handle_failure
)
self.assertEqual("test: 0", exc.args[0])
def test_custom_handle_failure_cancel_raise(self):
uuid = '136254d5-3869-408f-9da7-190e0072641a'
class MyException(Exception):
pass
def handle_failure(query):
# ensure the query is usable
result = query.count()
self.assertEqual(0, result)
return True
specimen = MyModel(
id=2, y='y1', z='z3', uuid=uuid
)
result = self.session.query(MyModel).update_on_match(
specimen, 'uuid', values={'x': 9, 'z': 'z3'},
handle_failure=handle_failure
)
self.assertEqual(uuid, result.uuid)
self.assertEqual(2, result.id)
self.assertEqual('z3', result.z)
self.assertEqual(9, result.x)
self.assertIn(result, self.session)
def test_update_specimen_on_none_successful(self):
uuid = 'bdf3893c-ee3c-40a0-bc79-960adb6cd1d4'
specimen = MyModel(
y='y2', z=None, uuid=uuid
)
result = self.session.query(MyModel).update_on_match(
specimen,
'uuid',
values={'x': 9, 'z': 'z3'},
)
self.assertIn(result, self.session)
self.assertEqual(uuid, result.uuid)
self.assertEqual(5, result.id)
self.assertEqual('z3', result.z)
self._assert_row(
5,
{
'uuid': 'bdf3893c-ee3c-40a0-bc79-960adb6cd1d4',
'x': 9, 'y': 'y2', 'z': 'z3'
}
)
def test_update_specimen_on_multiple_nonnone_successful(self):
uuid = '094eb162-d5df-494b-a458-a91a1b2d2c65'
specimen = MyModel(
y=('y1', 'y2'), x=(5, 7), uuid=uuid
)
result = self.session.query(MyModel).update_on_match(
specimen,
'uuid',
values={'x': 9, 'z': 'z3'},
)
self.assertIn(result, self.session)
self.assertEqual(uuid, result.uuid)
self.assertEqual(3, result.id)
self.assertEqual('z3', result.z)
self._assert_row(
3,
{
'uuid': '094eb162-d5df-494b-a458-a91a1b2d2c65',
'x': 9, 'y': 'y1', 'z': 'z3'
}
)
def test_update_specimen_on_multiple_wnone_successful(self):
uuid = 'bdf3893c-ee3c-40a0-bc79-960adb6cd1d4'
specimen = MyModel(
y=('y1', 'y2'), x=(8, 7), z=('z1', 'z2', None), uuid=uuid
)
result = self.session.query(MyModel).update_on_match(
specimen,
'uuid',
values={'x': 9, 'z': 'z3'},
)
self.assertIn(result, self.session)
self.assertEqual(uuid, result.uuid)
self.assertEqual(5, result.id)
self.assertEqual('z3', result.z)
self._assert_row(
5,
{
'uuid': 'bdf3893c-ee3c-40a0-bc79-960adb6cd1d4',
'x': 9, 'y': 'y2', 'z': 'z3'
}
)
def test_update_returning_pk_matched(self):
pk = self.session.query(MyModel).\
filter_by(y='y1', z='z2').update_returning_pk(
{'x': 9, 'z': 'z3'},
('uuid', '136254d5-3869-408f-9da7-190e0072641a')
)
self.assertEqual((2,), pk)
self._assert_row(
2,
{
'uuid': '136254d5-3869-408f-9da7-190e0072641a',
'x': 9, 'y': 'y1', 'z': 'z3'
}
)
def test_update_returning_wrong_uuid(self):
exc = self.assertRaises(
update_match.NoRowsMatched,
self.session.query(MyModel).
filter_by(y='y1', z='z2').update_returning_pk,
{'x': 9, 'z': 'z3'},
('uuid', '23cb9224-9f8e-40fe-bd3c-e7577b7af37d')
)
self.assertEqual("No rows matched the UPDATE", exc.args[0])
def test_update_returning_no_rows(self):
exc = self.assertRaises(
update_match.NoRowsMatched,
self.session.query(MyModel).
filter_by(y='y1', z='z3').update_returning_pk,
{'x': 9, 'z': 'z3'},
('uuid', '136254d5-3869-408f-9da7-190e0072641a')
)
self.assertEqual("No rows matched the UPDATE", exc.args[0])
def test_update_multiple_rows(self):
exc = self.assertRaises(
update_match.MultiRowsMatched,
self.session.query(MyModel).
filter_by(y='y1', z='z1').update_returning_pk,
{'x': 9, 'z': 'z3'},
('y', 'y1')
)
self.assertEqual("2 rows matched; expected one", exc.args[0])
class PGUpdateMatchTest(
UpdateMatchTest,
test_base.PostgreSQLOpportunisticTestCase):
pass
class MySQLUpdateMatchTest(
UpdateMatchTest,
test_base.MySQLOpportunisticTestCase):
pass