diff options
-rw-r--r-- | oslo_db/exception.py | 25 | ||||
-rw-r--r-- | oslo_db/sqlalchemy/exc_filters.py | 44 | ||||
-rw-r--r-- | oslo_db/sqlalchemy/migration.py | 10 | ||||
-rw-r--r-- | oslo_db/tests/old_import_api/sqlalchemy/test_exc_filters.py | 49 | ||||
-rw-r--r-- | oslo_db/tests/old_import_api/sqlalchemy/test_migration_common.py | 3 | ||||
-rw-r--r-- | oslo_db/tests/sqlalchemy/test_exc_filters.py | 110 | ||||
-rw-r--r-- | oslo_db/tests/sqlalchemy/test_migration_common.py | 3 | ||||
-rw-r--r-- | setup.cfg | 2 |
8 files changed, 234 insertions, 12 deletions
diff --git a/oslo_db/exception.py b/oslo_db/exception.py index f950f6a..506006c 100644 --- a/oslo_db/exception.py +++ b/oslo_db/exception.py @@ -87,6 +87,23 @@ class DBDuplicateEntry(DBError): super(DBDuplicateEntry, self).__init__(inner_exception) +class DBConstraintError(DBError): + """Check constraint fails for column error. + + Raised when made an attempt to write to a column a value that does not + satisfy a CHECK constraint. + + :kwarg table: the table name for which the check fails + :type table: str + :kwarg check_name: the table of the check that failed to be satisfied + :type check_name: str + """ + def __init__(self, table, check_name, inner_exception=None): + self.table = table + self.check_name = check_name + super(DBConstraintError, self).__init__(inner_exception) + + class DBReferenceError(DBError): """Foreign key violation error. @@ -155,6 +172,14 @@ class DBConnectionError(DBError): pass +class DBDataError(DBError): + """Raised for errors that are due to problems with the processed data. + + E.g. division by zero, numeric value out of range, incorrect data type, etc + + """ + + class InvalidSortKey(Exception): """A sort key destined for database query usage is invalid.""" diff --git a/oslo_db/sqlalchemy/exc_filters.py b/oslo_db/sqlalchemy/exc_filters.py index 777fda6..af8d992 100644 --- a/oslo_db/sqlalchemy/exc_filters.py +++ b/oslo_db/sqlalchemy/exc_filters.py @@ -91,8 +91,12 @@ def _deadlock_error(operational_error, match, engine_name, is_disconnect): @filters("mysql", sqla_exc.IntegrityError, - r"^.*\b1062\b.*Duplicate entry '(?P<value>[^']+)'" + r"^.*\b1062\b.*Duplicate entry '(?P<value>.+)'" r" for key '(?P<columns>[^']+)'.*$") +# NOTE(jd) For binary types +@filters("mysql", sqla_exc.IntegrityError, + r"^.*\b1062\b.*Duplicate entry \\'(?P<value>.+)\\'" + r" for key \\'(?P<columns>.+)\\'.*$") # NOTE(pkholkin): the first regex is suitable only for PostgreSQL 9.x versions # the second regex is suitable for PostgreSQL 8.x versions @filters("postgresql", sqla_exc.IntegrityError, @@ -187,12 +191,12 @@ def _sqlite_dupe_key_error(integrity_error, match, engine_name, is_disconnect): r"(?i).*foreign key constraint failed") @filters("postgresql", sqla_exc.IntegrityError, r".*on table \"(?P<table>[^\"]+)\" violates " - "foreign key constraint \"(?P<constraint>[^\"]+)\"\s*\n" + "foreign key constraint \"(?P<constraint>[^\"]+)\".*\n" "DETAIL: Key \((?P<key>.+)\)=\(.+\) " - "is not present in table " + "is (not present in|still referenced from) table " "\"(?P<key_table>[^\"]+)\".") @filters("mysql", sqla_exc.IntegrityError, - r".* u?'Cannot add or update a child row: " + r".* u?'Cannot (add|delete) or update a (child|parent) row: " 'a foreign key constraint fails \([`"].+[`"]\.[`"](?P<table>.+)[`"], ' 'CONSTRAINT [`"](?P<constraint>.+)[`"] FOREIGN KEY ' '\([`"](?P<key>.+)[`"]\) REFERENCES [`"](?P<key_table>.+)[`"] ') @@ -220,6 +224,26 @@ def _foreign_key_error(integrity_error, match, engine_name, is_disconnect): integrity_error) +@filters("postgresql", sqla_exc.IntegrityError, + r".*new row for relation \"(?P<table>.+)\" " + "violates check constraint " + "\"(?P<check_name>.+)\"") +def _check_constraint_error( + integrity_error, match, engine_name, is_disconnect): + """Filter for check constraint errors.""" + + try: + table = match.group("table") + except IndexError: + table = None + try: + check_name = match.group("check_name") + except IndexError: + check_name = None + + raise exception.DBConstraintError(table, check_name, integrity_error) + + @filters("ibm_db_sa", sqla_exc.IntegrityError, r"^.*SQL0803N.*$") def _db2_dupe_key_error(integrity_error, match, engine_name, is_disconnect): """Filter for DB2 duplicate key errors. @@ -250,6 +274,18 @@ def _raise_mysql_table_doesnt_exist_asis( raise error +@filters("mysql", sqla_exc.OperationalError, + r".*(1292|1366).*Incorrect \w+ value.*") +@filters("mysql", sqla_exc.DataError, + r".*1265.*Data truncated for column.*") +@filters("mysql", sqla_exc.DataError, + r".*1264.*Out of range value for column.*") +def _raise_data_error(error, match, engine_name, is_disconnect): + """Raise DBDataError exception for different data errors.""" + + raise exception.DBDataError(error) + + @filters("*", sqla_exc.OperationalError, r".*") def _raise_operational_errors_directly_filter(operational_error, match, engine_name, diff --git a/oslo_db/sqlalchemy/migration.py b/oslo_db/sqlalchemy/migration.py index 308ce60..d07ffc8 100644 --- a/oslo_db/sqlalchemy/migration.py +++ b/oslo_db/sqlalchemy/migration.py @@ -76,10 +76,14 @@ def db_sync(engine, abs_path, version=None, init_version=0, sanity_check=True): if sanity_check: _db_schema_sanity_check(engine) if version is None or version > current_version: - return versioning_api.upgrade(engine, repository, version) + migration = versioning_api.upgrade(engine, repository, version) else: - return versioning_api.downgrade(engine, repository, - version) + migration = versioning_api.downgrade(engine, repository, + version) + if sanity_check: + _db_schema_sanity_check(engine) + + return migration def _db_schema_sanity_check(engine): diff --git a/oslo_db/tests/old_import_api/sqlalchemy/test_exc_filters.py b/oslo_db/tests/old_import_api/sqlalchemy/test_exc_filters.py index 4d4609a..77670be 100644 --- a/oslo_db/tests/old_import_api/sqlalchemy/test_exc_filters.py +++ b/oslo_db/tests/old_import_api/sqlalchemy/test_exc_filters.py @@ -78,6 +78,9 @@ class TestsExceptionFilter(_SQLAExceptionMatcher, oslo_test_base.BaseTestCase): class ProgrammingError(Error): pass + class DataError(Error): + pass + class TransactionRollbackError(OperationalError): """Special psycopg2-only error class. @@ -223,6 +226,52 @@ class TestFallthroughsAndNonDBAPI(TestsExceptionFilter): self.assertEqual("mysqldb has an attribute error", matched.args[0]) +class TestDataError(TestsExceptionFilter): + + def test_mysql_incorrect_value(self): + matched = self._run_test( + "mysql", "insert into testtbl (id, b) values (4242, 4242)", + self.OperationalError( + "ERROR 1292 (22007): Incorrect datetime value: '4242' " + "for column 'b' at row 1" + ), + exception.DBDataError + ) + self.assertInnerException( + matched, + "OperationalError", + ("ERROR 1292 (22007): Incorrect datetime value: '4242' for column " + "'b' at row 1"), + "insert into testtbl (id, b) values (4242, 4242)", ()) + + def test_mysql_data_truncated_for_column(self): + matched = self._run_test( + "mysql", "insert into testtbl (id, b) values (4242, '42aabbccdd')", + self.DataError( + "ERROR 1265 (01000): Data truncated for column 'b' at row 1"), + exception.DBDataError + ) + self.assertInnerException( + matched, + "DataError", + "ERROR 1265 (01000): Data truncated for column 'b' at row 1", + "insert into testtbl (id, b) values (4242, '42aabbccdd')", ()) + + def test_mysql_out_of_range_value(self): + matched = self._run_test( + "mysql", "insert into testtbl (id, b) values (4242, 424242424242)", + self.DataError( + "ERROR 1264 (22003): Out of range value for column 'b' " + "at row 1"), + exception.DBDataError + ) + self.assertInnerException( + matched, + "DataError", + "ERROR 1264 (22003): Out of range value for column 'b' at row 1", + "insert into testtbl (id, b) values (4242, 424242424242)", ()) + + class TestReferenceErrorSQLite(_SQLAExceptionMatcher, test_base.DbTestCase): def setUp(self): diff --git a/oslo_db/tests/old_import_api/sqlalchemy/test_migration_common.py b/oslo_db/tests/old_import_api/sqlalchemy/test_migration_common.py index 98ae46e..c81c93a 100644 --- a/oslo_db/tests/old_import_api/sqlalchemy/test_migration_common.py +++ b/oslo_db/tests/old_import_api/sqlalchemy/test_migration_common.py @@ -158,7 +158,8 @@ class TestMigrationCommon(test_base.DbTestCase): mock_find_repo.return_value = self.return_value migration.db_sync(self.engine, self.path, self.test_version) - mock_sanity.assert_called_once_with(self.engine) + self.assertEqual([mock.call(self.engine), mock.call(self.engine)], + mock_sanity.call_args_list) def test_db_sync_sanity_skipped(self): with test_utils.nested( diff --git a/oslo_db/tests/sqlalchemy/test_exc_filters.py b/oslo_db/tests/sqlalchemy/test_exc_filters.py index aafdcfb..dc5de84 100644 --- a/oslo_db/tests/sqlalchemy/test_exc_filters.py +++ b/oslo_db/tests/sqlalchemy/test_exc_filters.py @@ -233,14 +233,14 @@ class TestReferenceErrorSQLite(_SQLAExceptionMatcher, test_base.DbTestCase): meta = sqla.MetaData(bind=self.engine) - table_1 = sqla.Table( + self.table_1 = sqla.Table( "resource_foo", meta, sqla.Column("id", sqla.Integer, primary_key=True), sqla.Column("foo", sqla.Integer), mysql_engine='InnoDB', mysql_charset='utf8', ) - table_1.create() + self.table_1.create() self.table_2 = sqla.Table( "resource_entity", meta, @@ -274,6 +274,30 @@ class TestReferenceErrorSQLite(_SQLAExceptionMatcher, test_base.DbTestCase): self.assertIsNone(matched.key) self.assertIsNone(matched.key_table) + def test_raise_delete(self): + self.engine.execute("PRAGMA foreign_keys = ON;") + + with self.engine.connect() as conn: + conn.execute(self.table_1.insert({"id": 1234, "foo": 42})) + conn.execute(self.table_2.insert({"id": 4321, "foo_id": 1234})) + matched = self.assertRaises( + exception.DBReferenceError, + self.engine.execute, + self.table_1.delete() + ) + self.assertInnerException( + matched, + "IntegrityError", + "foreign key constraint failed", + "DELETE FROM resource_foo", + (), + ) + + self.assertIsNone(matched.table) + self.assertIsNone(matched.constraint) + self.assertIsNone(matched.key) + self.assertIsNone(matched.key_table) + class TestReferenceErrorPostgreSQL(TestReferenceErrorSQLite, test_base.PostgreSQLOpportunisticTestCase): @@ -300,6 +324,31 @@ class TestReferenceErrorPostgreSQL(TestReferenceErrorSQLite, self.assertEqual("foo_id", matched.key) self.assertEqual("resource_foo", matched.key_table) + def test_raise_delete(self): + with self.engine.connect() as conn: + conn.execute(self.table_1.insert({"id": 1234, "foo": 42})) + conn.execute(self.table_2.insert({"id": 4321, "foo_id": 1234})) + matched = self.assertRaises( + exception.DBReferenceError, + self.engine.execute, + self.table_1.delete() + ) + self.assertInnerException( + matched, + "IntegrityError", + "update or delete on table \"resource_foo\" violates foreign key " + "constraint \"foo_fkey\" on table \"resource_entity\"\n" + "DETAIL: Key (id)=(1234) is still referenced from " + "table \"resource_entity\".\n", + "DELETE FROM resource_foo", + {}, + ) + + self.assertEqual("resource_foo", matched.table) + self.assertEqual("foo_fkey", matched.constraint) + self.assertEqual("id", matched.key) + self.assertEqual("resource_entity", matched.key_table) + class TestReferenceErrorMySQL(TestReferenceErrorSQLite, test_base.MySQLOpportunisticTestCase): @@ -353,6 +402,45 @@ class TestReferenceErrorMySQL(TestReferenceErrorSQLite, self.assertEqual("foo_id", matched.key) self.assertEqual("resource_foo", matched.key_table) + def test_raise_delete(self): + with self.engine.connect() as conn: + conn.execute(self.table_1.insert({"id": 1234, "foo": 42})) + conn.execute(self.table_2.insert({"id": 4321, "foo_id": 1234})) + matched = self.assertRaises( + exception.DBReferenceError, + self.engine.execute, + self.table_1.delete() + ) + self.assertInnerException( + matched, + "IntegrityError", + "(1451, 'cannot delete or update a parent row: a foreign key " + "constraint fails (`{0}`.`resource_entity`, " + "constraint `foo_fkey` " + "foreign key (`foo_id`) references " + "`resource_foo` (`id`))')".format(self.engine.url.database), + "DELETE FROM resource_foo", + (), + ) + + self.assertEqual("resource_entity", matched.table) + self.assertEqual("foo_fkey", matched.constraint) + self.assertEqual("foo_id", matched.key) + self.assertEqual("resource_foo", matched.key_table) + + +class TestConstraint(TestsExceptionFilter): + def test_postgresql(self): + matched = self._run_test( + "postgresql", "insert into resource some_values", + self.IntegrityError( + "new row for relation \"resource\" violates " + "check constraint \"ck_started_before_ended\""), + exception.DBConstraintError, + ) + self.assertEqual("resource", matched.table) + self.assertEqual("ck_started_before_ended", matched.check_name) + class TestDuplicate(TestsExceptionFilter): @@ -424,6 +512,24 @@ class TestDuplicate(TestsExceptionFilter): expected_value='2' ) + def test_mysql_binary(self): + self._run_dupe_constraint_test( + "mysql", + "(1062, \'Duplicate entry " + "\\\'\\\\x8A$\\\\x8D\\\\xA6\"s\\\\x8E\\\' " + "for key \\\'PRIMARY\\\'\')", + expected_columns=['PRIMARY'], + expected_value="\\\\x8A$\\\\x8D\\\\xA6\"s\\\\x8E" + ) + self._run_dupe_constraint_test( + "mysql", + "(1062, \'Duplicate entry " + "''\\\\x8A$\\\\x8D\\\\xA6\"s\\\\x8E!,' " + "for key 'PRIMARY'\')", + expected_columns=['PRIMARY'], + expected_value="'\\\\x8A$\\\\x8D\\\\xA6\"s\\\\x8E!," + ) + def test_postgresql_single(self): self._run_dupe_constraint_test( 'postgresql', diff --git a/oslo_db/tests/sqlalchemy/test_migration_common.py b/oslo_db/tests/sqlalchemy/test_migration_common.py index 95efab1..ebdaaa9 100644 --- a/oslo_db/tests/sqlalchemy/test_migration_common.py +++ b/oslo_db/tests/sqlalchemy/test_migration_common.py @@ -174,7 +174,8 @@ class TestMigrationCommon(test_base.DbTestCase): mock_find_repo.return_value = self.return_value migration.db_sync(self.engine, self.path, self.test_version) - mock_sanity.assert_called_once_with(self.engine) + self.assertEqual([mock.call(self.engine), mock.call(self.engine)], + mock_sanity.call_args_list) def test_db_sync_sanity_skipped(self): with test_utils.nested( @@ -1,6 +1,6 @@ [metadata] name = oslo.db -summary = oslo.db library +summary = Oslo Database library description-file = README.rst author = OpenStack |