当SQLAlchemy引发ProgrammingError

时间:2018-04-25 00:55:20

标签: python postgresql transactions sqlalchemy psycopg2

我在SQLAlchemy中遇到了一个稍微不寻常的事务状态和错误处理问题。简短版本:有什么方法可以在SQLAlchemy提出ProgrammingError并中止它时保留一个事务?

背景

我正在为遗留代码库开发集成测试套件。现在,我正在设计一套装置,允许我们在the SQLAlchemy documentation的启发下运行交易中的所有测试。一般范例涉及打开连接,启动事务,将会话绑定到该连接,然后模拟大多数数据库访问方法,以便他们使用该事务。 (要了解它的外观,请参阅上面文档链接中提供的代码,包括最后的注释。)目标是允许自己从代码库运行执行大量数据库更新的方法。测试的上下文,保证在测试完成后,任何改变测试数据库的副作用都会被回滚。

我的问题是代码经常依赖于处理DBAPI错误来在运行查询时完成控制流,而这些错误会自动中止事务(每the psycopg2 docs)。这会带来一个问题,因为我需要保留在该事务中已完成的工作,直到引发错误为止,并且我需要在完成错误处理后继续使用该事务。

这是一个使用错误处理控制流程的代表性方法:

from api.database import engine

def entity_count(): 
    """
    Count the entities in a project.
    """

    get_count = ''' 
        SELECT COUNT(*) AS entity_count FROM entity_browser 
    ''' 

    with engine.begin() as conn:
        try: 
            count = conn.execute(count).first().entity_count 
        except ProgrammingError: 
            count = 0 

return count 

在此示例中,错误处理提供了一种快速确定表entity_browser是否存在的方法:如果不存在,Postgres将抛出一个错误,该错误在DBAPI级别(psycopg2)被捕获并传递给SQLAlchemy as ProgrammingError

在测试中,我模拟engine.begin(),以便它始终返回与测试设置中建立的正在进行的事务的连接。不幸的是,这意味着当SQLAlchemy引发ProgrammingError并且psycopg2中止了事务后代码继续执行时,SQLAlchemy将在下次使用open连接运行数据库查询时引发InternalError,抱怨交易已经中止。

以下是展示此行为的示例测试:

import sqlalchemy as sa

def test_entity_count(session):
    """
    Test the `entity_count` method.

    `session` is a fixture that sets up the transaction and mocks out
    database access, returning a Flask-SQLAlchemy `scoped_session` object
    that we can use for queries.
    """

    # Make a change to a table that we can observe later
    session.execute('''
        UPDATE users
        SET name = 'in a test transaction'
        WHERE id = 1
    ''')

    # Drop `entity_browser` in order to raise a `ProgrammingError` later
    session.execute('''DROP TABLE entity_browser''')

    # Run the `entity_count` method, making sure that it raises an error
    with pytest.raises(sa.exc.ProgrammingError):
        count = entity_count()

    assert count == 0

    # Make sure that the changes we made earlier in the test still exist
    altered_name = session.execute('''
        SELECT name
        FROM users
        WHERE id = 1
    ''')

    assert altered_name == 'in a test transaction'

这是我得到的输出类型:

> altered_name = session.execute('''
      SELECT name
      FROM users
      WHERE id = 1
  ''')

[... traceback history...]

def do_execute(self, cursor, statement, parameters, context=None):
>   cursor.execute(statement, parameters)
E   sqlalchemy.exc.InternalError: (psycopg2.InternalError) current transaction is
    aborted, commands ignored until end of transaction block

尝试解决方案

我的第一直觉是尝试中断错误处理并使用SQLAlchemy的handle_error event listener强制回滚。我在测试夹具中添加了一个侦听器,它将回滚原始连接(因为SQLAlchemy Connection实例没有回滚API,据我所知):

@sa.event.listens_for(connection, 'handle_error')
def raise_error(context):
    dbapi_conn = context.connection.connection
    dbapi_conn.rollback()

这会成功保持事务处理以供进一步使用,但最终会回滚测试中所有先前的更改。样本输出:

> assert altered_name == 'in a test transaction'
E AssertionError

显然,回滚原始连接过于激进了。考虑到我可以回滚到最后一个保存点,我尝试回滚作用域会话,该会话附加了一个事件监听器,当前一个会话结束时会自动打开一个新的嵌套事务。 (请参阅transactions in tests上SQLAlchemy文档末尾的注释,了解其外观示例。)

由于在session fixture中设置了模拟,我可以将作用域会话直接导入事件监听器并将其回滚:

@sa.event.listens_for(connection, 'handle_error')
def raise_error(context):
    from api.database import db
    db.session.rollback()

但是,这种方法在下一个查询中也会引发InternalError。看起来它实际上并没有将事务回滚到底层游标的满意程度。

摘要问题

ProgrammingError被提升后有没有办法保留交易?在更抽象的层面上,当psycopg2“中止”事务时会发生什么,以及如何解决它?

1 个答案:

答案 0 :(得分:3)

问题的根源在于您正在从上下文管理器中隐藏异常。你过早地捕获about.html因此with语句永远不会看到它。您的ProgrammingError应为:

entity_count()

然后,如果您提供类似

的内容
def entity_count(): 
    """
    Count the entities in a project.
    """

    get_count = ''' 
        SELECT COUNT(*) AS entity_count FROM entity_browser 
    ''' 

    try:
        with engine.begin() as conn:
            count = conn.execute(get_count).first().entity_count

    except ProgrammingError: 
        count = 0 

return count

作为模拟的@contextmanager def fake_begin(): """ Begin a nested transaction and yield the global connection. """ with connection.begin_nested(): yield connection ,连接保持可用。但@JL Peyret提出了关于测试逻辑的一个好点。 Engine.begin()通常 1 通过池中的武装交易提供新连接,因此engine.begin()session甚至不应该使用相同的连接。

1 :取决于池配置。