我在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“中止”事务时会发生什么,以及如何解决它?
答案 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 :取决于池配置。