#    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.

"""Test the compatibility layer for the handle_error() event.

This event is added as of SQLAlchemy 0.9.7; oslo.db provides a compatibility
layer for prior SQLAlchemy versions.

"""
import contextlib

import mock
from oslotest import base as test_base
import sqlalchemy as sqla
from sqlalchemy.sql import column
from sqlalchemy.sql import literal
from sqlalchemy.sql import select
from sqlalchemy.types import Integer
from sqlalchemy.types import TypeDecorator

from oslo.db.sqlalchemy.compat import handle_error
from oslo.db.sqlalchemy.compat import utils


class MyException(Exception):
    pass


class ExceptionReraiseTest(test_base.BaseTestCase):

    def setUp(self):
        super(ExceptionReraiseTest, self).setUp()

        self.engine = engine = sqla.create_engine("sqlite://")
        self.addCleanup(engine.dispose)

    def _fixture(self):
        engine = self.engine

        def err(context):
            if "ERROR ONE" in str(context.statement):
                raise MyException("my exception")
        handle_error(engine, err)

    def test_exception_event_altered(self):
        self._fixture()

        with mock.patch.object(self.engine.dialect.execution_ctx_cls,
                               "handle_dbapi_exception") as patched:

            matchee = self.assertRaises(
                MyException,
                self.engine.execute, "SELECT 'ERROR ONE' FROM I_DONT_EXIST"
            )
            self.assertEqual(1, patched.call_count)
            self.assertEqual("my exception", matchee.args[0])

    def test_exception_event_non_altered(self):
        self._fixture()

        with mock.patch.object(self.engine.dialect.execution_ctx_cls,
                               "handle_dbapi_exception") as patched:

            self.assertRaises(
                sqla.exc.DBAPIError,
                self.engine.execute, "SELECT 'ERROR TWO' FROM I_DONT_EXIST"
            )
            self.assertEqual(1, patched.call_count)

    def test_is_disconnect_not_interrupted(self):
        self._fixture()

        with contextlib.nested(
            mock.patch.object(
                self.engine.dialect.execution_ctx_cls,
                "handle_dbapi_exception"
            ),
            mock.patch.object(
                self.engine.dialect, "is_disconnect",
                lambda *args: True
            )
        ) as (handle_dbapi_exception, is_disconnect):
            with self.engine.connect() as conn:
                self.assertRaises(
                    MyException,
                    conn.execute, "SELECT 'ERROR ONE' FROM I_DONT_EXIST"
                )
                self.assertEqual(1, handle_dbapi_exception.call_count)
                self.assertTrue(conn.invalidated)

    def test_no_is_disconnect_not_invalidated(self):
        self._fixture()

        with contextlib.nested(
            mock.patch.object(
                self.engine.dialect.execution_ctx_cls,
                "handle_dbapi_exception"
            ),
            mock.patch.object(
                self.engine.dialect, "is_disconnect",
                lambda *args: False
            )
        ) as (handle_dbapi_exception, is_disconnect):
            with self.engine.connect() as conn:
                self.assertRaises(
                    MyException,
                    conn.execute, "SELECT 'ERROR ONE' FROM I_DONT_EXIST"
                )
                self.assertEqual(1, handle_dbapi_exception.call_count)
                self.assertFalse(conn.invalidated)

    def test_exception_event_ad_hoc_context(self):
        engine = self.engine

        nope = MyException("nope")

        class MyType(TypeDecorator):
            impl = Integer

            def process_bind_param(self, value, dialect):
                raise nope

        listener = mock.Mock(return_value=None)
        handle_error(engine, listener)

        self.assertRaises(
            sqla.exc.StatementError,
            engine.execute,
            select([1]).where(column('foo') == literal('bar', MyType))
        )

        ctx = listener.mock_calls[0][1][0]
        self.assertTrue(ctx.statement.startswith("SELECT 1 "))
        self.assertIs(ctx.is_disconnect, False)
        self.assertIs(ctx.original_exception, nope)

    def _test_alter_disconnect(self, orig_error, evt_value):
        engine = self.engine

        def evt(ctx):
            ctx.is_disconnect = evt_value
        handle_error(engine, evt)

        # if we are under sqla 0.9.7, and we are expecting to take
        # an "is disconnect" exception and make it not a disconnect,
        # that isn't supported b.c. the wrapped handler has already
        # done the invalidation.
        expect_failure = not utils.sqla_097 and orig_error and not evt_value

        with mock.patch.object(engine.dialect,
                               "is_disconnect",
                               mock.Mock(return_value=orig_error)):

            with engine.connect() as c:
                conn_rec = c.connection._connection_record
                try:
                    c.execute("SELECT x FROM nonexistent")
                    assert False
                except sqla.exc.StatementError as st:
                    self.assertFalse(expect_failure)

                    # check the exception's invalidation flag
                    self.assertEqual(st.connection_invalidated, evt_value)

                    # check the Connection object's invalidation flag
                    self.assertEqual(c.invalidated, evt_value)

                    # this is the ConnectionRecord object; it's invalidated
                    # when its .connection member is None
                    self.assertEqual(conn_rec.connection is None, evt_value)

                except NotImplementedError as ne:
                    self.assertTrue(expect_failure)
                    self.assertEqual(
                        str(ne),
                        "Can't reset 'disconnect' status of exception once it "
                        "is set with this version of SQLAlchemy")

    def test_alter_disconnect_to_true(self):
        self._test_alter_disconnect(False, True)
        self._test_alter_disconnect(True, True)

    def test_alter_disconnect_to_false(self):
        self._test_alter_disconnect(True, False)
        self._test_alter_disconnect(False, False)
