Skip to content

Commit

Permalink
SQLAlchemy Panel - log transactional events
Browse files Browse the repository at this point in the history
Use the SQLAlchemy Events system to add transactional markers (such as
`begin`, `commit`, `rollback`) to the debug payload

* redo of Pylons#299
* closes Pylons#236
  • Loading branch information
jvanasco committed Oct 22, 2020
1 parent 9299442 commit 176015d
Show file tree
Hide file tree
Showing 4 changed files with 347 additions and 4 deletions.
1 change: 1 addition & 0 deletions setup.cfg
Expand Up @@ -50,6 +50,7 @@ testing =
WebTest
nose
coverage
sqlalchemy
docs =
Sphinx >= 1.7.5
pylons-sphinx-themes >= 0.3
Expand Down
72 changes: 72 additions & 0 deletions src/pyramid_debugtoolbar/panels/sqla.py
Expand Up @@ -45,7 +45,79 @@ def _after_cursor_execute(conn, cursor, stmt, params, context, execmany):
)
delattr(conn, 'pdtb_start_timer')

def _transactional_event_logger(conn, stmt, context=''):
"""
Abstract logger for transactional events
"""
request = get_current_request()
if request is not None and hasattr(request, 'pdtb_sqla_queries'):
with lock:
engines = request.registry.pdtb_sqla_engines
engines[id(conn.engine)] = weakref.ref(conn.engine)
queries = request.pdtb_sqla_queries
queries.append(
{
'engine_id': id(conn.engine),
'duration': 0,
'statement': stmt,
'parameters': '',
'context': '',
}
)

@event.listens_for(Engine, "begin")
def _begin(conn):
stmt = 'begin;'
_transactional_event_logger(conn, stmt)

@event.listens_for(Engine, "commit")
def _commit(conn):
stmt = 'commit;'
_transactional_event_logger(conn, stmt)

@event.listens_for(Engine, "rollback")
def _rollback(conn):
stmt = 'rollback;'
_transactional_event_logger(conn, stmt)

@event.listens_for(Engine, "savepoint")
def _savepoint(conn, name):
stmt = 'savepoint %s;' % name
_transactional_event_logger(conn, stmt)

@event.listens_for(Engine, "rollback_savepoint")
def _rollback_savepoint(conn, name, context):
stmt = 'rollback_savepoint %s;' % name
_transactional_event_logger(conn, stmt, context)

@event.listens_for(Engine, "release_savepoint")
def _release_savepoint(conn, name, context):
stmt = 'release_savepoint %s;' % name
_transactional_event_logger(conn, stmt, context)

@event.listens_for(Engine, "begin_twophase")
def _begin_twophase(conn, xid):
stmt = 'begin_twophase %s;' % xid
_transactional_event_logger(conn, stmt)

@event.listens_for(Engine, "prepare_twophase")
def _prepare_twophase(conn, xid):
stmt = 'prepare_twophase %s;' % xid
_transactional_event_logger(conn, stmt)

@event.listens_for(Engine, "commit_twophase")
def _commit_twophase(conn, xid, is_prepared):
stmt = 'commit_twophase %s %s;' % (xid, is_prepared)
_transactional_event_logger(conn, stmt)

@event.listens_for(Engine, "rollback_twophase")
def _rollback_twophase(conn, xid, is_prepared):
stmt = 'rollback_twophase %s %s;' % (xid, is_prepared)
_transactional_event_logger(conn, stmt)

has_sqla = True

except ImportError:
has_sqla = False

Expand Down
12 changes: 8 additions & 4 deletions tests/test_panels/_utils.py
Expand Up @@ -10,6 +10,13 @@
)


def ok_response_factory():
return Response(
"<html><head></head><body>OK</body></html>",
content_type="text/html",
)


class _TestDebugtoolbarPanel(unittest.TestCase):
def setUp(self):
self.re_toolbar_link = re_toolbar_link
Expand All @@ -19,10 +26,7 @@ def setUp(self):

# create a view
def empty_view(request):
return Response(
"<html><head></head><body>OK</body></html>",
content_type="text/html",
)
return ok_response_factory()

config.add_view(empty_view)
self.app = self.config.make_wsgi_app()
Expand Down
266 changes: 266 additions & 0 deletions tests/test_panels/test_sqla.py
@@ -0,0 +1,266 @@
from pyramid import testing
from pyramid.request import Request
import sqlalchemy
from sqlalchemy.sql import text as sqla_text
import sys

from pyramid_debugtoolbar.compat import PY3

from ._utils import (
_TestDebugtoolbarPanel,
ok_response_factory,
re_toolbar_link,
)


class _TestSQLAlchemyPanel(_TestDebugtoolbarPanel):
"""
Base class for testing SQLAlchemy panel
"""

config = None
app = None

def _sqlalchemy_view(self, context, request):
"""
This function should define a Pyramid view
* (potentially) invoke SQLAlchemy
* return a Response
"""
raise NotImplementedError()

def setUp(self):
self.config = config = testing.setUp()
config.include("pyramid_debugtoolbar")
config.add_view(self._sqlalchemy_view)
self.app = config.make_wsgi_app()

def tearDown(self):
testing.tearDown()

def _makeOne(self):
"""
Makes a request to the main App
* which invokes `self._sqlalchemy_view`
* Make a request to the toolbar
* return the toolbar Response
"""
# make the app
app = self.config.make_wsgi_app()
# make a request
req1 = Request.blank("/")
req1.remote_addr = "127.0.0.1"
resp1 = req1.get_response(app)
self.assertEqual(resp1.status_code, 200)
self.assertIn("http://localhost/_debug_toolbar/", resp1.text)

# check the toolbar
links = re_toolbar_link.findall(resp1.text)
self.assertIsNotNone(links)
self.assertIsInstance(links, list)
self.assertEqual(len(links), 1)
toolbar_link = links[0]

req2 = Request.blank(toolbar_link)
req2.remote_addr = "127.0.0.1"
resp2 = req2.get_response(app)

return resp2

def _check_rendered__panel(self, resp):
"""
Ensure the rendered panel exists with statements
"""
self.assertIn('<li class="" id="pDebugPanel-sqlalchemy">', resp.text)
self.assertIn(
'<div id="pDebugPanel-sqlalchemy-content" class="panelContent" '
'style="display: none;">',
resp.text,
)

def _check_rendered__select_null(self, resp):
"""
Ensure the rendered panel has the "SELECT NULL" statement rendered
Note: the <pre> styles are different on the Py2 and Py3 libraries
"""
self.assertIn(
'<span style="color: #008800; font-weight: bold">SELECT</span> '
'<span style="color: #008800; font-weight: bold">NULL</span>',
resp.text,
)

def _check_rendered__begin_rollback(self, resp):
self.assertIn(
'<span style="color: #008800; font-weight: bold">begin</span>;\n',
resp.text,
)
self.assertIn(
'<span style="color: #008800; font-weight: bold">rollback</span>;'
'\n',
resp.text,
)

def _check_rendered__begin_commit(self, resp):
self.assertIn(
'<span style="color: #008800; font-weight: bold">begin</span>;\n',
resp.text,
)
self.assertIn(
'<span style="color: #008800; font-weight: bold">commit</span>;\n',
resp.text,
)


class TestNone(_TestSQLAlchemyPanel):
"""
No SQLAlchemy queries
"""

def _sqlalchemy_view(self, context, request):
return ok_response_factory()

def test_panel(self):
resp = self._makeOne()
self.assertEqual(resp.status_code, 200)
self.assertIn(
'<li class="disabled" id="pDebugPanel-sqlalchemy">', resp.text
)
self.assertNotIn(
'<div id="pDebugPanel-sqlalchemy-content" class="panelContent" '
'style="display: none;">',
resp.text,
)


class TestSimpleSelect(_TestSQLAlchemyPanel):
"""
A simple SELECT
"""

def _sqlalchemy_view(self, context, request):
engine = sqlalchemy.create_engine("sqlite://", isolation_level=None)
conn = engine.connect()
stmt = sqla_text("SELECT NULL;")
conn.execute(stmt) # noqa
return ok_response_factory()

def test_panel(self):
resp = self._makeOne()
self.assertEqual(resp.status_code, 200)
self._check_rendered__panel(resp)
self._check_rendered__select_null(resp)


class TestTransactionCommit(_TestSQLAlchemyPanel):
"""
A simple transaction
"""

def _sqlalchemy_view(self, context, request):
engine = sqlalchemy.create_engine("sqlite://", isolation_level=None)
conn = engine.connect()
with conn.begin():
stmt = sqla_text("SELECT NULL;")
conn.execute(stmt) # noqa
return ok_response_factory()

def test_panel(self):
resp = self._makeOne()
self.assertEqual(resp.status_code, 200)
self._check_rendered__panel(resp)
self._check_rendered__select_null(resp)
self._check_rendered__begin_commit(resp)


class TestTransactionRollback(_TestSQLAlchemyPanel):
def _sqlalchemy_view(self, context, request):
engine = sqlalchemy.create_engine("sqlite://", isolation_level=None)
conn = engine.connect()
try:
with conn.begin():
stmt = sqla_text("SELECT NULL;")
conn.execute(stmt) # noqa
raise ValueError("EXPECTED")
except ValueError:
# SQLAlchemy's ContextManager will call a rollback
pass
return ok_response_factory()

def test_panel(self):
resp = self._makeOne()
self.assertEqual(resp.status_code, 200)
self._check_rendered__panel(resp)
self._check_rendered__select_null(resp)
self._check_rendered__begin_rollback(resp)


class TestTransactionComplex(_TestSQLAlchemyPanel):
def _sqlalchemy_view(self, context, request):
"""
Test to ensure the following listeners do not raise Exceptions:
[+] begin
[+] commit
[+] rollback
[+] savepoint
[+] rollback_savepoint
[+] release_savepoint
[+] begin_twophase
[ ] prepare_twophase
[ ] commit_twophase
[ ] rollback_twophase
"""

engine = sqlalchemy.create_engine("sqlite://", isolation_level=None)
if not PY3 or (sys.version_info[1] <= 5):
# under Python2-Python3.5, Sqlite needs workaround to support savepoints
# see https://docs.sqlalchemy.org/en/13/dialects/sqlite.html

@sqlalchemy.event.listens_for(engine, "connect")
def _do_connect(dbapi_connection, connection_record):
# disable pysqlite's emitting of the BEGIN statement entirely.
# also stops it from emitting COMMIT before any DDL.
dbapi_connection.isolation_level = None

@sqlalchemy.event.listens_for(engine, "begin")
def _do_begin(conn):
conn.execute("BEGIN")

conn = engine.connect()

# tests: `begin`, `commit`
t1 = conn.begin() # `begin`
conn.execute(sqla_text("SELECT NULL; -- a"))
t1.commit() # `commit`

# tests: `begin`, `rollback`
t1 = conn.begin() # `begin`
conn.execute(sqla_text("SELECT NULL; -- b"))
t1.rollback() # `rollback`

# tests: `begin`, `savepoint`, `rollback_savepoint`, `release_savepoint`
t1 = conn.begin() # `begin`
conn.execute(sqla_text("SELECT NULL; -- c1"))
t2 = conn.begin_nested() # `savepoint`
conn.execute(sqla_text("SELECT NULL; -- c2"))
t2.rollback() # `rollback_savepoint`
t2 = conn.begin_nested() # `savepoint`
conn.execute(sqla_text("SELECT NULL; -- c3"))
t2.commit() # `release_savepoint`
conn.execute(sqla_text("SELECT NULL; -- c4"))
t1.commit() # commit

# tests: `begin_twophase`
try:
conn.begin_twophase() # `begin_twophase`
except NotImplementedError:
pass

# untested: `prepare_twophase`, `commit_twophase`, `rollback_twophase`

return ok_response_factory()

def test_panel(self):
resp = self._makeOne()
self.assertEqual(resp.status_code, 200)
self._check_rendered__panel(resp)

0 comments on commit 176015d

Please sign in to comment.