Reading the queries in a test#

record_queries() records the SQL statements a block sends and lets the test read them back:

from pytest_queryguard import record_queries

from app.customers import fetch_customers
from app.orders_fixed import list_orders


def test_list_orders_loads_customers_with_the_orders(session):
    with record_queries() as log:
        list_orders(session)
    assert log.count == 1
    assert "JOIN customer" in log.queries[0].sql
    assert all(query.relationship is None for query in log)


def test_fetch_customers(session):
    with record_queries() as log:
        fetch_customers(session, [1, 2, 3])
    log.assert_no_nplusone(repeat_threshold=2)

The two tests import from different modules of the first test project: list_orders from app/orders_fixed.py, which loads the customers with the orders, and fetch_customers from app/customers.py, the version with the loop, which is the one that fails the second test. Against the N+1 app.orders the first test fails on log.count == 1.

log.queries is a tuple of Query objects, in the order the statements ran. The log is also sized, iterable and indexable (len(log), for query in log, log[i] and log[a:b]) but not a collections.abc.Sequence, because log.count is a number and not the count(value) method of a sequence. An empty log is falsy. Each Query has:

log.count is the number of statements. Both grow inside the block and stay fixed after it. Statements from other threads are recorded too.

QueryLog is not constructible by users: record_queries() is the only way to get one, and its constructor may change in any release. Use the class for type hints only.

log.assert_no_nplusone() applies the plugin's N+1 rules to the block: relationship loads, repeated statements and column loads, plus small repeats and write loops when asked for. It raises NPlusOneDetected, a subclass of AssertionError, with the plugin's message, with paths relative to pytest's rootdir (outside pytest, to the working directory). The thresholds are keyword-only arguments, with the same defaults as the ini options:

Argument Default Ini option Rule
relationship_threshold 2 queryguard_relationship_threshold One relationship lazy-loaded for this many different parent objects.
repeat_threshold 5 queryguard_repeat_threshold One SELECT that ran this many times.
column_load_threshold 5 queryguard_column_load_threshold This many instances of one class each loading deferred or expired columns on their own (kind column-load).
small_repeat_threshold None (off) queryguard_small_repeat_threshold An integer of at least 2 and below repeat_threshold: a statement that ran this many times from one line, each time with other parameters, counts too.
write_loop_threshold None (off) queryguard_write_loop_threshold An integer of at least 2: this many single-row INSERT, UPDATE or DELETE statements of one shape from one line (kind write-loop).
repeat_scope "test" queryguard_repeat_scope See below.

An ini option set to 0 for "off" is None here.

repeat_scope="session" counts each Session on its own, like queryguard_repeat_scope = session (see Tests with several sessions or requests); the default is "test".

It does not read the ini options, queryguard_mode or the allow_nplusone marker; calling it is an explicit request. A project that sets queryguard_repeat_threshold = 10 in its configuration therefore has to pass repeat_threshold=10 here as well, or the helper keeps using 5 (the same for queryguard_relationship_threshold and relationship_threshold). With the fetch_customers loop from the first test, the plugin accepts 3 lookups, because its threshold is 5, while the stricter call above does not (pytest --tb=short, Python 3.13, SQLAlchemy 2.1):

tests/test_queries.py .F                                                 [100%]

=================================== FAILURES ===================================
_____________________________ test_fetch_customers _____________________________
tests/test_queries.py:18: in test_fetch_customers
    log.assert_no_nplusone(repeat_threshold=2)
E   pytest_queryguard.NPlusOneDetected: queryguard: N+1 detected
E   
E     the same statement ran 3 times
E     first at app/customers.py:9 in fetch_customers
E     kind: repeated-statement  label: SELECT customer.id, customer.name FROM customer WHERE customer.id = ?
E     fix: fetch all rows in one query (WHERE id IN (...)) instead of in a loop
=========================== short test summary info ============================
FAILED tests/test_queries.py::test_fetch_customers - pytest_queryguard.NPlusO...
========================= 1 failed, 1 passed in 0.05s ==========================

If pytest-queryguard hits an internal error, it stops recording and issues a QueryGuardInternalWarning that names the listener that failed; the session summary repeats it with the error and the line that raised it. A record_queries() block that ends after such an error then warns that its log may be incomplete (the warning class is exported from pytest_queryguard), and log.assert_no_nplusone() raises RuntimeError instead of passing on a log that may be empty. This also holds outside pytest and with --queryguard-mode=off. The warning is issued when the block ends normally; a block left by an exception does not warn, so the warning never replaces the exception, but its log is still fixed at that point and assert_no_nplusone() still refuses it. An "error" warnings filter does not turn this warning into an exception either.

What is public API, and what is not, is defined on the Stability and versioning page. In short: the names in pytest_queryguard.__all__ and the pytest options, markers and finding kinds are public; the constructors of QueryLog, Query and CallSite are not. record_queries() reads the queries; max_queries() and max_db_time() yield the same QueryLog, so with max_queries(3) as log: also gives log.count, log.queries and log.slowest().

Recording changes nothing else. The plugin's N+1 detection, budgets and index check see the same statements. Like max_queries(), record_queries() also works outside pytest.

Edit this page on GitHub