summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--oslo.db/locale/en_GB/LC_MESSAGES/oslo.db-log-info.po18
-rw-r--r--oslo.db/locale/oslo.db-log-info.pot12
-rw-r--r--oslo/db/exception.py8
-rw-r--r--oslo/db/sqlalchemy/provision.py522
-rw-r--r--oslo/db/sqlalchemy/session.py3
-rw-r--r--oslo/db/sqlalchemy/test_base.py87
-rw-r--r--oslo/db/sqlalchemy/test_migrations.py75
-rw-r--r--oslo/db/sqlalchemy/utils.py34
-rw-r--r--requirements.txt2
-rw-r--r--test-requirements-py2.txt4
-rw-r--r--test-requirements-py3.txt4
-rw-r--r--tests/sqlalchemy/test_migrations.py12
-rw-r--r--tests/sqlalchemy/test_utils.py59
-rw-r--r--tox.ini1
14 files changed, 648 insertions, 193 deletions
diff --git a/oslo.db/locale/en_GB/LC_MESSAGES/oslo.db-log-info.po b/oslo.db/locale/en_GB/LC_MESSAGES/oslo.db-log-info.po
index fc898eb..071fc4f 100644
--- a/oslo.db/locale/en_GB/LC_MESSAGES/oslo.db-log-info.po
+++ b/oslo.db/locale/en_GB/LC_MESSAGES/oslo.db-log-info.po
@@ -8,9 +8,9 @@ msgid ""
msgstr ""
"Project-Id-Version: oslo.db\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2014-09-12 06:00+0000\n"
-"PO-Revision-Date: 2014-09-02 09:18+0000\n"
-"Last-Translator: Andi Chandler <andi@gowling.com>\n"
+"POT-Creation-Date: 2014-11-10 06:02+0000\n"
+"PO-Revision-Date: 2014-11-08 14:37+0000\n"
+"Last-Translator: openstackjenkins <jenkins@openstack.org>\n"
"Language-Team: English (United Kingdom) (http://www.transifex.com/projects/p/"
"oslodb/language/en_GB/)\n"
"Language: en_GB\n"
@@ -20,12 +20,12 @@ msgstr ""
"Generated-By: Babel 1.3\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
-#: oslo/db/sqlalchemy/utils.py:433
+#: oslo/db/sqlalchemy/provision.py:151 oslo/db/sqlalchemy/provision.py:162
#, python-format
-msgid "Deleting duplicated row with id: %(id)s from table: %(table)s"
-msgstr "Deleting duplicated row with id: %(id)s from table: %(table)s"
+msgid "The %(dbapi)s backend is unavailable: %(err)s"
+msgstr ""
-#: oslo/db/sqlalchemy/utils.py:694
+#: oslo/db/sqlalchemy/utils.py:415
#, python-format
-msgid "The %(backend)s backend is unavailable: %(exception)s"
-msgstr "The %(backend)s backend is unavailable: %(exception)s"
+msgid "Deleting duplicated row with id: %(id)s from table: %(table)s"
+msgstr "Deleting duplicated row with id: %(id)s from table: %(table)s"
diff --git a/oslo.db/locale/oslo.db-log-info.pot b/oslo.db/locale/oslo.db-log-info.pot
index 66f8976..aee920a 100644
--- a/oslo.db/locale/oslo.db-log-info.pot
+++ b/oslo.db/locale/oslo.db-log-info.pot
@@ -6,9 +6,9 @@
#, fuzzy
msgid ""
msgstr ""
-"Project-Id-Version: oslo.db 0.3.0.70.g69f16bf\n"
+"Project-Id-Version: oslo.db 1.0.1.49.g04235f9\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2014-08-05 06:03+0000\n"
+"POT-Creation-Date: 2014-11-10 06:02+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,13 +17,13 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 1.3\n"
-#: oslo/db/sqlalchemy/utils.py:428
+#: oslo/db/sqlalchemy/provision.py:151 oslo/db/sqlalchemy/provision.py:162
#, python-format
-msgid "Deleting duplicated row with id: %(id)s from table: %(table)s"
+msgid "The %(dbapi)s backend is unavailable: %(err)s"
msgstr ""
-#: oslo/db/sqlalchemy/utils.py:689
+#: oslo/db/sqlalchemy/utils.py:415
#, python-format
-msgid "The %(backend)s backend is unavailable: %(exception)s"
+msgid "Deleting duplicated row with id: %(id)s from table: %(table)s"
msgstr ""
diff --git a/oslo/db/exception.py b/oslo/db/exception.py
index e549bc3..a96ad76 100644
--- a/oslo/db/exception.py
+++ b/oslo/db/exception.py
@@ -163,3 +163,11 @@ class InvalidSortKey(Exception):
class ColumnError(Exception):
"""Error raised when no column or an invalid column is found."""
+
+
+class BackendNotAvailable(Exception):
+ """Error raised when a particular database backend is not available
+
+ within a test suite.
+
+ """
diff --git a/oslo/db/sqlalchemy/provision.py b/oslo/db/sqlalchemy/provision.py
index f1aa2cd..260cb5c 100644
--- a/oslo/db/sqlalchemy/provision.py
+++ b/oslo/db/sqlalchemy/provision.py
@@ -15,111 +15,459 @@
"""Provision test environment for specific DB backends"""
+import abc
import argparse
-import copy
import logging
import os
import random
+import re
import string
+import six
from six import moves
import sqlalchemy
+from sqlalchemy.engine import url as sa_url
-from oslo.db import exception as exc
-
+from oslo.db._i18n import _LI
+from oslo.db import exception
+from oslo.db.sqlalchemy import session
+from oslo.db.sqlalchemy import utils
LOG = logging.getLogger(__name__)
-def get_engine(uri):
- """Engine creation
+class ProvisionedDatabase(object):
+ """Represent a single database node that can be used for testing in
+
+ a serialized fashion.
+
+ ``ProvisionedDatabase`` includes features for full lifecycle management
+ of a node, in a way that is context-specific. Depending on how the
+ test environment runs, ``ProvisionedDatabase`` should know if it needs
+ to create and drop databases or if it is making use of a database that
+ is maintained by an external process.
- Call the function without arguments to get admin connection. Admin
- connection required to create temporary database for each
- particular test. Otherwise use existing connection to recreate
- connection to the temporary database.
"""
- return sqlalchemy.create_engine(uri, poolclass=sqlalchemy.pool.NullPool)
+ def __init__(self, database_type):
+ self.backend = Backend.backend_for_database_type(database_type)
+ self.db_token = _random_ident()
-def _execute_sql(engine, sql, driver):
- """Initialize connection, execute sql query and close it."""
- try:
- with engine.connect() as conn:
- if driver == 'postgresql':
- conn.connection.set_isolation_level(0)
- for s in sql:
- conn.execute(s)
- except sqlalchemy.exc.OperationalError:
- msg = ('%s does not match database admin '
- 'credentials or database does not exist.')
- LOG.exception(msg, engine.url)
- raise exc.DBConnectionError(msg % engine.url)
-
-
-def create_database(engine):
- """Provide temporary database for each particular test."""
- driver = engine.name
-
- database = ''.join(random.choice(string.ascii_lowercase)
- for i in moves.range(10))
-
- if driver == 'sqlite':
- database = '/tmp/%s' % database
- elif driver in ['mysql', 'postgresql']:
- sql = 'create database %s;' % database
- _execute_sql(engine, [sql], driver)
- else:
- raise ValueError('Unsupported RDBMS %s' % driver)
-
- # Both shallow and deep copies may lead to surprising behaviour
- # without knowing the implementation of sqlalchemy.engine.url.
- # Use a shallow copy here, since we're only overriding a single
- # property, invoking __str__ and then discarding our copy. This
- # is currently safe and _should_ remain safe into the future.
- new_url = copy.copy(engine.url)
-
- new_url.database = database
- return str(new_url)
-
-
-def drop_database(admin_engine, current_uri):
- """Drop temporary database after each particular test."""
-
- engine = get_engine(current_uri)
- driver = engine.name
-
- if driver == 'sqlite':
+ self.backend.create_named_database(self.db_token)
+ self.engine = self.backend.provisioned_engine(self.db_token)
+
+ def dispose(self):
+ self.engine.dispose()
+ self.backend.drop_named_database(self.db_token)
+
+
+class Backend(object):
+ """Represent a particular database backend that may be provisionable.
+
+ The ``Backend`` object maintains a database type (e.g. database without
+ specific driver type, such as "sqlite", "postgresql", etc.),
+ a target URL, a base ``Engine`` for that URL object that can be used
+ to provision databases and a ``BackendImpl`` which knows how to perform
+ operations against this type of ``Engine``.
+
+ """
+
+ backends_by_database_type = {}
+
+ def __init__(self, database_type, url):
+ self.database_type = database_type
+ self.url = url
+ self.verified = False
+ self.engine = None
+ self.impl = BackendImpl.impl(database_type)
+ Backend.backends_by_database_type[database_type] = self
+
+ @classmethod
+ def backend_for_database_type(cls, database_type):
+ """Return and verify the ``Backend`` for the given database type.
+
+ Creates the engine if it does not already exist and raises
+ ``BackendNotAvailable`` if it cannot be produced.
+
+ :return: a base ``Engine`` that allows provisioning of databases.
+
+ :raises: ``BackendNotAvailable``, if an engine for this backend
+ cannot be produced.
+
+ """
+ try:
+ backend = cls.backends_by_database_type[database_type]
+ except KeyError:
+ raise exception.BackendNotAvailable(database_type)
+ else:
+ return backend._verify()
+
+ @classmethod
+ def all_viable_backends(cls):
+ """Return an iterator of all ``Backend`` objects that are present
+
+ and provisionable.
+
+ """
+
+ for backend in cls.backends_by_database_type.values():
+ try:
+ yield backend._verify()
+ except exception.BackendNotAvailable:
+ pass
+
+ def _verify(self):
+ """Verify that this ``Backend`` is available and provisionable.
+
+ :return: this ``Backend``
+
+ :raises: ``BackendNotAvailable`` if the backend is not available.
+
+ """
+
+ if not self.verified:
+ try:
+ eng = self._ensure_backend_available(self.url)
+ except exception.BackendNotAvailable:
+ raise
+ else:
+ self.engine = eng
+ finally:
+ self.verified = True
+ if self.engine is None:
+ raise exception.BackendNotAvailable(self.database_type)
+ return self
+
+ @classmethod
+ def _ensure_backend_available(cls, url):
+ url = sa_url.make_url(str(url))
try:
- os.remove(engine.url.database)
- except OSError:
- pass
- elif driver in ['mysql', 'postgresql']:
- sql = 'drop database %s;' % engine.url.database
- _execute_sql(admin_engine, [sql], driver)
- else:
- raise ValueError('Unsupported RDBMS %s' % driver)
+ eng = sqlalchemy.create_engine(url)
+ except ImportError as i_e:
+ # SQLAlchemy performs an "import" of the DBAPI module
+ # within create_engine(). So if ibm_db_sa, cx_oracle etc.
+ # isn't installed, we get an ImportError here.
+ LOG.info(
+ _LI("The %(dbapi)s backend is unavailable: %(err)s"),
+ dict(dbapi=url.drivername, err=i_e))
+ raise exception.BackendNotAvailable("No DBAPI installed")
+ else:
+ try:
+ conn = eng.connect()
+ except sqlalchemy.exc.DBAPIError as d_e:
+ # upon connect, SQLAlchemy calls dbapi.connect(). This
+ # usually raises OperationalError and should always at
+ # least raise a SQLAlchemy-wrapped DBAPI Error.
+ LOG.info(
+ _LI("The %(dbapi)s backend is unavailable: %(err)s"),
+ dict(dbapi=url.drivername, err=d_e)
+ )
+ raise exception.BackendNotAvailable("Could not connect")
+ else:
+ conn.close()
+ return eng
+
+ def create_named_database(self, ident):
+ """Create a database with the given name."""
+
+ self.impl.create_named_database(self.engine, ident)
+
+ def drop_named_database(self, ident, conditional=False):
+ """Drop a database with the given name."""
+
+ self.impl.drop_named_database(
+ self.engine, ident,
+ conditional=conditional)
+
+ def database_exists(self, ident):
+ """Return True if a database of the given name exists."""
+
+ return self.impl.database_exists(self.engine, ident)
+
+ def provisioned_engine(self, ident):
+ """Given the URL of a particular database backend and the string
+
+ name of a particular 'database' within that backend, return
+ an Engine instance whose connections will refer directly to the
+ named database.
+
+ For hostname-based URLs, this typically involves switching just the
+ 'database' portion of the URL with the given name and creating
+ an engine.
+
+ For URLs that instead deal with DSNs, the rules may be more custom;
+ for example, the engine may need to connect to the root URL and
+ then emit a command to switch to the named database.
+
+ """
+ return self.impl.provisioned_engine(self.url, ident)
+
+ @classmethod
+ def _setup(cls):
+ """Initial startup feature will scan the environment for configured
+
+ URLs and place them into the list of URLs we will use for provisioning.
+
+ This searches through OS_TEST_DBAPI_ADMIN_CONNECTION for URLs. If
+ not present, we set up URLs based on the "opportunstic" convention,
+ e.g. username+password = "openstack_citest".
+
+ The provisioning system will then use or discard these URLs as they
+ are requested, based on whether or not the target database is actually
+ found to be available.
+
+ """
+ configured_urls = os.getenv('OS_TEST_DBAPI_ADMIN_CONNECTION', None)
+ if configured_urls:
+ configured_urls = configured_urls.split(";")
+ else:
+ configured_urls = [
+ impl.create_opportunistic_driver_url()
+ for impl in BackendImpl.all_impls()
+ ]
+
+ for url_str in configured_urls:
+ url = sa_url.make_url(url_str)
+ m = re.match(r'([^+]+?)(?:\+(.+))?$', url.drivername)
+ database_type, drivertype = m.group(1, 2)
+ Backend(database_type, url)
+
+
+@six.add_metaclass(abc.ABCMeta)
+class BackendImpl(object):
+ """Provide database-specific implementations of key provisioning
+
+ functions.
+
+ ``BackendImpl`` is owned by a ``Backend`` instance which delegates
+ to it for all database-specific features.
+
+ """
+
+ @classmethod
+ def all_impls(cls):
+ """Return an iterator of all possible BackendImpl objects.
+
+ These are BackendImpls that are implemented, but not
+ necessarily provisionable.
+
+ """
+ for database_type in cls.impl.reg:
+ if database_type == '*':
+ continue
+ yield BackendImpl.impl(database_type)
+ @utils.dispatch_for_dialect("*")
+ def impl(drivername):
+ """Return a ``BackendImpl`` instance corresponding to the
-def main():
- """Controller to handle commands
+ given driver name.
+
+ This is a dispatched method which will refer to the constructor
+ of implementing subclasses.
+
+ """
+ raise NotImplementedError(
+ "No provision impl available for driver: %s" % drivername)
+
+ def __init__(self, drivername):
+ self.drivername = drivername
+
+ @abc.abstractmethod
+ def create_opportunistic_driver_url(self):
+ """Produce a string url known as the 'opportunistic' URL.
+
+ This URL is one that corresponds to an established Openstack
+ convention for a pre-established database login, which, when
+ detected as available in the local environment, is automatically
+ used as a test platform for a specific type of driver.
+
+ """
+
+ @abc.abstractmethod
+ def create_named_database(self, engine, ident):
+ """Create a database with the given name."""
+
+ @abc.abstractmethod
+ def drop_named_database(self, engine, ident, conditional=False):
+ """Drop a database with the given name."""
+
+ def provisioned_engine(self, base_url, ident):
+ """Return a provisioned engine.
+
+ Given the URL of a particular database backend and the string
+ name of a particular 'database' within that backend, return
+ an Engine instance whose connections will refer directly to the
+ named database.
+
+ For hostname-based URLs, this typically involves switching just the
+ 'database' portion of the URL with the given name and creating
+ an engine.
+
+ For URLs that instead deal with DSNs, the rules may be more custom;
+ for example, the engine may need to connect to the root URL and
+ then emit a command to switch to the named database.
+
+ """
+
+ url = sa_url.make_url(str(base_url))
+ url.database = ident
+ return session.create_engine(
+ url,
+ logging_name="%s@%s" % (self.drivername, ident))
+
+
+@BackendImpl.impl.dispatch_for("mysql")
+class MySQLBackendImpl(BackendImpl):
+ def create_opportunistic_driver_url(self):
+ return "mysql://openstack_citest:openstack_citest@localhost/"
+
+ def create_named_database(self, engine, ident):
+ with engine.connect() as conn:
+ conn.execute("CREATE DATABASE %s" % ident)
+
+ def drop_named_database(self, engine, ident, conditional=False):
+ with engine.connect() as conn:
+ if not conditional or self.database_exists(conn, ident):
+ conn.execute("DROP DATABASE %s" % ident)
+
+ def database_exists(self, engine, ident):
+ return bool(engine.scalar("SHOW DATABASES LIKE '%s'" % ident))
+
+
+@BackendImpl.impl.dispatch_for("sqlite")
+class SQLiteBackendImpl(BackendImpl):
+ def create_opportunistic_driver_url(self):
+ return "sqlite://"
+
+ def create_named_database(self, engine, ident):
+ url = self._provisioned_database_url(engine.url, ident)
+ eng = sqlalchemy.create_engine(url)
+ eng.connect().close()
+
+ def provisioned_engine(self, base_url, ident):
+ return session.create_engine(
+ self._provisioned_database_url(base_url, ident))
+
+ def drop_named_database(self, engine, ident, conditional=False):
+ url = self._provisioned_database_url(engine.url, ident)
+ filename = url.database
+ if filename and (not conditional or os.access(filename, os.F_OK)):
+ os.remove(filename)
+
+ def database_exists(self, engine, ident):
+ url = self._provisioned_database_url(engine.url, ident)
+ filename = url.database
+ return not filename or os.access(filename, os.F_OK)
+
+ def _provisioned_database_url(self, base_url, ident):
+ if base_url.database:
+ return sa_url.make_url("sqlite:////tmp/%s.db" % ident)
+ else:
+ return base_url
+
+
+@BackendImpl.impl.dispatch_for("postgresql")
+class PostgresqlBackendImpl(BackendImpl):
+ def create_opportunistic_driver_url(self):
+ return "postgresql://openstack_citest:openstack_citest"\
+ "@localhost/postgres"
+
+ def create_named_database(self, engine, ident):
+ with engine.connect().execution_options(
+ isolation_level="AUTOCOMMIT") as conn:
+ conn.execute("CREATE DATABASE %s" % ident)
+
+ def drop_named_database(self, engine, ident, conditional=False):
+ with engine.connect().execution_options(
+ isolation_level="AUTOCOMMIT") as conn:
+ self._close_out_database_users(conn, ident)
+ if conditional:
+ conn.execute("DROP DATABASE IF EXISTS %s" % ident)
+ else:
+ conn.execute("DROP DATABASE %s" % ident)
+
+ def database_exists(self, engine, ident):
+ return bool(
+ engine.scalar(
+ sqlalchemy.text(
+ "select datname from pg_database "
+ "where datname=:name"), name=ident)
+ )
+
+ def _close_out_database_users(self, conn, ident):
+ """Attempt to guarantee a database can be dropped.
+
+ Optional feature which guarantees no connections with our
+ username are attached to the DB we're going to drop.
+
+ This method has caveats; for one, the 'pid' column was named
+ 'procpid' prior to Postgresql 9.2. But more critically,
+ prior to 9.2 this operation required superuser permissions,
+ even if the connections we're closing are under the same username
+ as us. In more recent versions this restriction has been
+ lifted for same-user connections.
+
+ """
+ if conn.dialect.server_version_info >= (9, 2):
+ conn.execute(
+ sqlalchemy.text(
+ "select pg_terminate_backend(pid) "
+ "from pg_stat_activity "
+ "where usename=current_user and "
+ "pid != pg_backend_pid() "
+ "and datname=:dname"
+ ), dname=ident)
+
+
+def _random_ident():
+ return ''.join(
+ random.choice(string.ascii_lowercase)
+ for i in moves.range(10))
+
+
+def _echo_cmd(args):
+ idents = [_random_ident() for i in moves.range(args.instances_count)]
+ print("\n".join(idents))
+
+
+def _create_cmd(args):
+ idents = [_random_ident() for i in moves.range(args.instances_count)]
+
+ for backend in Backend.all_viable_backends():
+ for ident in idents:
+ backend.create_named_database(ident)
+
+ print("\n".join(idents))
+
+
+def _drop_cmd(args):
+ for backend in Backend.all_viable_backends():
+ for ident in args.instances:
+ backend.drop_named_database(ident, args.conditional)
+
+Backend._setup()
+
+
+def main(argv=None):
+ """Command line interface to create/drop databases.
::create: Create test database with random names.
::drop: Drop database created by previous command.
+ ::echo: create random names and display them; don't create.
"""
parser = argparse.ArgumentParser(
description='Controller to handle database creation and dropping'
' commands.',
- epilog='Under normal circumstances is not used directly.'
- ' Used in .testr.conf to automate test database creation'
- ' and dropping processes.')
+ epilog='Typically called by the test runner, e.g. shell script, '
+ 'testr runner via .testr.conf, or other system.')
subparsers = parser.add_subparsers(
help='Subcommands to manipulate temporary test databases.')
create = subparsers.add_parser(
'create',
help='Create temporary test databases.')
- create.set_defaults(which='create')
+ create.set_defaults(which=_create_cmd)
create.add_argument(
'instances_count',
type=int,
@@ -128,25 +476,31 @@ def main():
drop = subparsers.add_parser(
'drop',
help='Drop temporary test databases.')
- drop.set_defaults(which='drop')
+ drop.set_defaults(which=_drop_cmd)
drop.add_argument(
'instances',
nargs='+',
help='List of databases uri to be dropped.')
+ drop.add_argument(
+ '--conditional',
+ action="store_true",
+ help="Check if database exists first before dropping"
+ )
- args = parser.parse_args()
+ echo = subparsers.add_parser(
+ 'echo',
+ help="Create random database names and display only."
+ )
+ echo.set_defaults(which=_echo_cmd)
+ echo.add_argument(
+ 'instances_count',
+ type=int,
+ help='Number of identifiers to create.')
- connection_string = os.getenv('OS_TEST_DBAPI_ADMIN_CONNECTION',
- 'sqlite://')
- engine = get_engine(connection_string)
- which = args.which
+ args = parser.parse_args(argv)
- if which == "create":
- for i in range(int(args.instances_count)):
- print(create_database(engine))
- elif which == "drop":
- for db in args.instances:
- drop_database(engine, db)
+ cmd = args.which
+ cmd(args)
if __name__ == "__main__":
diff --git a/oslo/db/sqlalchemy/session.py b/oslo/db/sqlalchemy/session.py
index b24e06b..937494c 100644
--- a/oslo/db/sqlalchemy/session.py
+++ b/oslo/db/sqlalchemy/session.py
@@ -368,7 +368,7 @@ def create_engine(sql_connection, sqlite_fk=False, mysql_sql_mode=None,
connection_debug=0, max_pool_size=None, max_overflow=None,
pool_timeout=None, sqlite_synchronous=True,
connection_trace=False, max_retries=10, retry_interval=10,
- thread_checkin=True):
+ thread_checkin=True, logging_name=None):
"""Return a new SQLAlchemy engine."""
url = sqlalchemy.engine.url.make_url(sql_connection)
@@ -377,6 +377,7 @@ def create_engine(sql_connection, sqlite_fk=False, mysql_sql_mode=None,
"pool_recycle": idle_timeout,
'convert_unicode': True,
'connect_args': {},
+ 'logging_name': logging_name
}
_setup_logging(connection_debug)
diff --git a/oslo/db/sqlalchemy/test_base.py b/oslo/db/sqlalchemy/test_base.py
index 02a356a..d483fad 100644
--- a/oslo/db/sqlalchemy/test_base.py
+++ b/oslo/db/sqlalchemy/test_base.py
@@ -13,9 +13,6 @@
# License for the specific language governing permissions and limitations
# under the License.
-import abc
-import os
-
import fixtures
try:
@@ -24,9 +21,10 @@ except ImportError:
raise NameError('Oslotest is not installed. Please add oslotest in your'
' test-requirements')
+
import six
-import testtools
+from oslo.db import exception
from oslo.db.sqlalchemy import provision
from oslo.db.sqlalchemy import session
from oslo.db.sqlalchemy import utils
@@ -41,8 +39,12 @@ class DbFixture(fixtures.Fixture):
credentials for specific backend.
"""
- def _get_uri(self):
- return os.getenv('OS_TEST_DBAPI_CONNECTION', 'sqlite://')
+ DRIVER = "sqlite"
+
+ # these names are deprecated, and are not used by DbFixture.
+ # they are here for backwards compatibility with test suites that
+ # are referring to them directly.
+ DBNAME = PASSWORD = USERNAME = 'openstack_citest'
def __init__(self, test):
super(DbFixture, self).__init__()
@@ -52,9 +54,17 @@ class DbFixture(fixtures.Fixture):
def setUp(self):
super(DbFixture, self).setUp()
- self.test.engine = session.create_engine(self._get_uri())
- self.addCleanup(self.test.engine.dispose)
- self.test.sessionmaker = session.get_maker(self.test.engine)
+ try:
+ self.provision = provision.ProvisionedDatabase(self.DRIVER)
+ self.addCleanup(self.provision.dispose)
+ except exception.BackendNotAvailable:
+ msg = '%s backend is not available.' % self.DRIVER
+ return self.test.skip(msg)
+ else:
+ self.test.engine = self.provision.engine
+ self.addCleanup(setattr, self.test, 'engine', None)
+ self.test.sessionmaker = session.get_maker(self.test.engine)
+ self.addCleanup(setattr, self.test, 'sessionmaker', None)
class DbTestCase(test_base.BaseTestCase):
@@ -72,6 +82,9 @@ class DbTestCase(test_base.BaseTestCase):
self.useFixture(self.FIXTURE(self))
+class OpportunisticTestCase(DbTestCase):
+ """Placeholder for backwards compatibility."""
+
ALLOWED_DIALECTS = ['sqlite', 'mysql', 'postgresql']
@@ -98,64 +111,12 @@ def backend_specific(*dialects):
return wrap
-@six.add_metaclass(abc.ABCMeta)
-class OpportunisticFixture(DbFixture):
- """Base fixture to use default CI databases.
-
- The databases exist in OpenStack CI infrastructure. But for the
- correct functioning in local environment the databases must be
- created manually.
- """
-
- DRIVER = abc.abstractproperty(lambda: None)
- DBNAME = PASSWORD = USERNAME = 'openstack_citest'
- _uri = None
-
- def _get_uri(self):
- if self._uri is not None:
- return self._uri
-
- credentials = {
- 'backend': self.DRIVER,
- 'user': self.USERNAME,
- 'passwd': self.PASSWORD,
- 'database': self.DBNAME}
- if self.DRIVER and not utils.is_backend_avail(**credentials):
- msg = '%s backend is not available.' % self.DRIVER
- raise testtools.testcase.TestSkipped(msg)
-
- self._provisioning_engine = provision.get_engine(
- utils.get_connect_string(backend=self.DRIVER,
- user=self.USERNAME,
- passwd=self.PASSWORD,
- database=self.DBNAME)
- )
- self._uri = provision.create_database(self._provisioning_engine)
- self.addCleanup(
- provision.drop_database, self._provisioning_engine, self._uri)
- self.addCleanup(setattr, self, '_uri', None)
- return self._uri
-
-
-@six.add_metaclass(abc.ABCMeta)
-class OpportunisticTestCase(DbTestCase):
- """Base test case to use default CI databases.
-
- The subclasses of the test case are running only when openstack_citest
- database is available otherwise tests will be skipped.
- """
-
- FIXTURE = abc.abstractproperty(lambda: None)
-
-
-class MySQLOpportunisticFixture(OpportunisticFixture):
+class MySQLOpportunisticFixture(DbFixture):
DRIVER = 'mysql'
- DBNAME = '' # connect to MySQL server, but not to the openstack_citest db
-class PostgreSQLOpportunisticFixture(OpportunisticFixture):
+class PostgreSQLOpportunisticFixture(DbFixture):
DRIVER = 'postgresql'
- DBNAME = 'postgres' # PostgreSQL requires the db name here,use service one
class MySQLOpportunisticTestCase(OpportunisticTestCase):
diff --git a/oslo/db/sqlalchemy/test_migrations.py b/oslo/db/sqlalchemy/test_migrations.py
index 28b613f..c6bd146 100644
--- a/oslo/db/sqlalchemy/test_migrations.py
+++ b/oslo/db/sqlalchemy/test_migrations.py
@@ -15,6 +15,7 @@
# under the License.
import abc
+import collections
import logging
import pprint
@@ -510,6 +511,73 @@ class ModelsMigrationsSync(object):
for table in tbs:
conn.execute(schema.DropTable(table))
+ FKInfo = collections.namedtuple('fk_info', ['constrained_columns',
+ 'referred_table',
+ 'referred_columns'])
+
+ def check_foreign_keys(self, metadata, bind):
+ """Compare foreign keys between model and db table.
+
+ :returns: a list that contains information about:
+
+ * should be a new key added or removed existing,
+ * name of that key,
+ * source table,
+ * referred table,
+ * constrained columns,
+ * referred columns
+
+ Output::
+
+ [('drop_key',
+ 'testtbl_fk_check_fkey',
+ 'testtbl',
+ fk_info(constrained_columns=(u'fk_check',),
+ referred_table=u'table',
+ referred_columns=(u'fk_check',)))]
+
+ """
+
+ diff = []
+ insp = sqlalchemy.engine.reflection.Inspector.from_engine(bind)
+ # Get all tables from db
+ db_tables = insp.get_table_names()
+ # Get all tables from models
+ model_tables = metadata.tables
+ for table in db_tables:
+ if table not in model_tables:
+ continue
+ # Get all necessary information about key of current table from db
+ fk_db = dict((self._get_fk_info_from_db(i), i['name'])
+ for i in insp.get_foreign_keys(table))
+ fk_db_set = set(fk_db.keys())
+ # Get all necessary information about key of current table from
+ # models
+ fk_models = dict((self._get_fk_info_from_model(fk), fk)
+ for fk in model_tables[table].foreign_keys)
+ fk_models_set = set(fk_models.keys())
+ for key in (fk_db_set - fk_models_set):
+ diff.append(('drop_key', fk_db[key], table, key))
+ LOG.info(("Detected removed foreign key %(fk)r on "
+ "table %(table)r"), {'fk': fk_db[key],
+ 'table': table})
+ for key in (fk_models_set - fk_db_set):
+ diff.append(('add_key', fk_models[key], key))
+ LOG.info((
+ "Detected added foreign key for column %(fk)r on table "
+ "%(table)r"), {'fk': fk_models[key].column.name,
+ 'table': table})
+ return diff
+
+ def _get_fk_info_from_db(self, fk):
+ return self.FKInfo(tuple(fk['constrained_columns']),
+ fk['referred_table'],
+ tuple(fk['referred_columns']))
+
+ def _get_fk_info_from_model(self, fk):
+ return self.FKInfo((fk.parent.name,), fk.column.table.name,
+ (fk.column.name,))
+
def test_models_sync(self):
# recent versions of sqlalchemy and alembic are needed for running of
# this test, but we already have them in requirements
@@ -534,8 +602,11 @@ class ModelsMigrationsSync(object):
mc = alembic.migration.MigrationContext.configure(conn, opts=opts)
# compare schemas and fail with diff, if it's not empty
- diff = alembic.autogenerate.compare_metadata(mc,
- self.get_metadata())
+ diff1 = alembic.autogenerate.compare_metadata(mc,
+ self.get_metadata())
+ diff2 = self.check_foreign_keys(self.get_metadata(),
+ self.get_engine())
+ diff = diff1 + diff2
if diff:
msg = pprint.pformat(diff, indent=2, width=20)
self.fail(
diff --git a/oslo/db/sqlalchemy/utils.py b/oslo/db/sqlalchemy/utils.py
index e2537a6..ccac7cd 100644
--- a/oslo/db/sqlalchemy/utils.py
+++ b/oslo/db/sqlalchemy/utils.py
@@ -649,6 +649,11 @@ def get_connect_string(backend, database, user=None, passwd=None,
Try to get a connection with a very specific set of values, if we get
these then we'll run the tests, otherwise they are skipped
+
+ DEPRECATED: this function is deprecated and will be removed from oslo.db
+ in a few releases. Please use the provisioning system for dealing
+ with URLs and database provisioning.
+
"""
args = {'backend': backend,
'user': user,
@@ -663,22 +668,25 @@ def get_connect_string(backend, database, user=None, passwd=None,
def is_backend_avail(backend, database, user=None, passwd=None):
+ """Return True if the given backend is available.
+
+
+ DEPRECATED: this function is deprecated and will be removed from oslo.db
+ in a few releases. Please use the provisioning system to access
+ databases based on backend availability.
+
+ """
+ from oslo.db.sqlalchemy import provision
+
+ connect_uri = get_connect_string(backend=backend,
+ database=database,
+ user=user,
+ passwd=passwd)
try:
- connect_uri = get_connect_string(backend=backend,
- database=database,
- user=user,
- passwd=passwd)
- engine = sqlalchemy.create_engine(connect_uri)
- connection = engine.connect()
- except Exception as e:
- # intentionally catch all to handle exceptions even if we don't
- # have any backend code loaded.
- msg = _LI("The %(backend)s backend is unavailable: %(exception)s")
- LOG.info(msg, {"backend": backend, "exception": e})
+ provision.Backend._ensure_backend_available(connect_uri)
+ except exception.BackendNotAvailable:
return False
else:
- connection.close()
- engine.dispose()
return True
diff --git a/requirements.txt b/requirements.txt
index 24d3452..cc50660 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,5 +10,5 @@ oslo.config>=1.4.0 # Apache-2.0
oslo.utils>=1.0.0 # Apache-2.0
SQLAlchemy>=0.8.4,<=0.8.99,>=0.9.7,<=0.9.99
sqlalchemy-migrate>=0.9.1,!=0.9.2
-stevedore>=1.0.0 # Apache-2.0
+stevedore>=1.1.0 # Apache-2.0
six>=1.7.0
diff --git a/test-requirements-py2.txt b/test-requirements-py2.txt
index efb80d4..13cea90 100644
--- a/test-requirements-py2.txt
+++ b/test-requirements-py2.txt
@@ -13,7 +13,7 @@ psycopg2
python-subunit>=0.0.18
sphinx>=1.1.2,!=1.2.0,!=1.3b1,<1.3
oslosphinx>=2.2.0 # Apache-2.0
-oslotest>=1.1.0 # Apache-2.0
+oslotest>=1.2.0 # Apache-2.0
testrepository>=0.0.18
testscenarios>=0.4
-testtools>=0.9.34
+testtools>=0.9.36
diff --git a/test-requirements-py3.txt b/test-requirements-py3.txt
index 3d33d0e..4f195da 100644
--- a/test-requirements-py3.txt
+++ b/test-requirements-py3.txt
@@ -12,10 +12,10 @@ psycopg2
python-subunit>=0.0.18
sphinx>=1.1.2,!=1.2.0,!=1.3b1,<1.3
oslosphinx>=2.2.0 # Apache-2.0
-oslotest>=1.1.0 # Apache-2.0
+oslotest>=1.2.0 # Apache-2.0
testrepository>=0.0.18
testscenarios>=0.4
-testtools>=0.9.34
+testtools>=0.9.36
# TODO(harlowja): add in pymysql when able to...
# https://review.openstack.org/#/c/123737
diff --git a/tests/sqlalchemy/test_migrations.py b/tests/sqlalchemy/test_migrations.py
index abb5f8a..db6c9c8 100644
--- a/tests/sqlalchemy/test_migrations.py
+++ b/tests/sqlalchemy/test_migrations.py
@@ -194,6 +194,7 @@ class ModelsMigrationSyncMixin(test.BaseTestCase):
sa.Column('defaulttest4', sa.Enum('first', 'second',
name='testenum'),
server_default="first"),
+ sa.Column('fk_check', sa.String(36), nullable=False),
sa.UniqueConstraint('spam', 'eggs', name='uniq_cons'),
)
@@ -210,6 +211,7 @@ class ModelsMigrationSyncMixin(test.BaseTestCase):
eggs = sa.Column('eggs', sa.DateTime)
foo = sa.Column('foo', sa.Boolean,
server_default=sa.sql.expression.true())
+ fk_check = sa.Column('fk_check', sa.String(36), nullable=False)
bool_wo_default = sa.Column('bool_wo_default', sa.Boolean)
defaulttest = sa.Column('defaulttest',
sa.Integer, server_default='5')
@@ -243,6 +245,12 @@ class ModelsMigrationSyncMixin(test.BaseTestCase):
def _test_models_not_sync(self):
self.metadata_migrations.clear()
sa.Table(
+ 'table', self.metadata_migrations,
+ sa.Column('fk_check', sa.String(36), nullable=False),
+ sa.PrimaryKeyConstraint('fk_check'),
+ mysql_engine='InnoDB'
+ )
+ sa.Table(
'testtbl', self.metadata_migrations,
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('spam', sa.String(8), nullable=True),
@@ -257,7 +265,10 @@ class ModelsMigrationSyncMixin(test.BaseTestCase):
sa.Column('defaulttest4',
sa.Enum('first', 'second', name='testenum'),
server_default="first"),
+ sa.Column('fk_check', sa.String(36), nullable=False),
sa.UniqueConstraint('spam', 'foo', name='uniq_cons'),
+ sa.ForeignKeyConstraint(['fk_check'], ['table.fk_check']),
+ mysql_engine='InnoDB'
)
msg = six.text_type(self.assertRaises(AssertionError,
@@ -276,6 +287,7 @@ class ModelsMigrationSyncMixin(test.BaseTestCase):
self.assertIn('bool_wo_default', msg)
self.assertIn('defaulttest', msg)
self.assertIn('defaulttest3', msg)
+ self.assertIn('drop_key', msg)
class ModelsMigrationsSyncMysql(ModelsMigrationSyncMixin,
diff --git a/tests/sqlalchemy/test_utils.py b/tests/sqlalchemy/test_utils.py
index acf31d0..c13fe9d 100644
--- a/tests/sqlalchemy/test_utils.py
+++ b/tests/sqlalchemy/test_utils.py
@@ -26,13 +26,15 @@ from sqlalchemy.dialects import mysql
from sqlalchemy import Boolean, Index, Integer, DateTime, String, SmallInteger
from sqlalchemy import MetaData, Table, Column, ForeignKey
from sqlalchemy.engine import reflection
-from sqlalchemy.exc import ResourceClosedError
+from sqlalchemy.engine import url as sa_url
+from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import select
from sqlalchemy.types import UserDefinedType, NullType
from oslo.db import exception
from oslo.db.sqlalchemy import models
+from oslo.db.sqlalchemy import provision
from oslo.db.sqlalchemy import session
from oslo.db.sqlalchemy import test_base as db_test_base
from oslo.db.sqlalchemy import utils
@@ -537,19 +539,58 @@ class TestConnectionUtils(test_utils.BaseTestCase):
def test_is_backend_unavail(self):
log = self.useFixture(fixtures.FakeLogger())
- error_cause = ('This result object does not return rows. It has been'
- 'closed automatically.')
- error_msg = ("The %s backend is unavailable: %s\n" %
- ('mysql', error_cause))
-
+ err = OperationalError("Can't connect to database", None, None)
+ error_msg = "The mysql backend is unavailable: %s\n" % err
self.mox.StubOutWithMock(sqlalchemy.engine.base.Engine, 'connect')
- sqlalchemy.engine.base.Engine.connect().AndRaise(
- ResourceClosedError(error_cause))
+ sqlalchemy.engine.base.Engine.connect().AndRaise(err)
self.mox.ReplayAll()
-
self.assertFalse(utils.is_backend_avail(**self.full_credentials))
self.assertEqual(error_msg, log.output)
+ def test_ensure_backend_available(self):
+ self.mox.StubOutWithMock(sqlalchemy.engine.base.Engine, 'connect')
+ fake_connection = self.mox.CreateMockAnything()
+ fake_connection.close()
+ sqlalchemy.engine.base.Engine.connect().AndReturn(fake_connection)
+ self.mox.ReplayAll()
+
+ eng = provision.Backend._ensure_backend_available(self.connect_string)
+ self.assertIsInstance(eng, sqlalchemy.engine.base.Engine)
+ self.assertEqual(self.connect_string, str(eng.url))
+
+ def test_ensure_backend_available_no_connection_raises(self):
+ log = self.useFixture(fixtures.FakeLogger())
+ err = OperationalError("Can't connect to database", None, None)
+ self.mox.StubOutWithMock(sqlalchemy.engine.base.Engine, 'connect')
+ sqlalchemy.engine.base.Engine.connect().AndRaise(err)
+ self.mox.ReplayAll()
+
+ exc = self.assertRaises(
+ exception.BackendNotAvailable,
+ provision.Backend._ensure_backend_available, self.connect_string
+ )
+ self.assertEqual("Could not connect", str(exc))
+ self.assertEqual(
+ "The mysql backend is unavailable: %s" % err,
+ log.output.strip())
+
+ def test_ensure_backend_available_no_dbapi_raises(self):
+ log = self.useFixture(fixtures.FakeLogger())
+ self.mox.StubOutWithMock(sqlalchemy, 'create_engine')
+ sqlalchemy.create_engine(
+ sa_url.make_url(self.connect_string)).AndRaise(
+ ImportError("Can't import DBAPI module foobar"))
+ self.mox.ReplayAll()
+
+ exc = self.assertRaises(
+ exception.BackendNotAvailable,
+ provision.Backend._ensure_backend_available, self.connect_string
+ )
+ self.assertEqual("No DBAPI installed", str(exc))
+ self.assertEqual(
+ "The mysql backend is unavailable: Can't import "
+ "DBAPI module foobar", log.output.strip())
+
def test_get_db_connection_info(self):
conn_pieces = parse.urlparse(self.connect_string)
self.assertEqual(utils.get_db_connection_info(conn_pieces),
diff --git a/tox.ini b/tox.ini
index 635486c..e1fa3e0 100644
--- a/tox.ini
+++ b/tox.ini
@@ -55,7 +55,6 @@ commands =
show-source = True
ignore = E123,E125,H305,H307,H803,H904
-builtins = _
exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build
[hacking]