v0.166.0
  1"""
  2Database test helpers.
  3"""
  4
  5from collections.abc import Generator
  6from contextlib import contextmanager
  7from dataclasses import dataclass
  8from functools import partial
  9from typing import Any
 10from weakref import WeakKeyDictionary
 11
 12from plain.testing import Captured, CaptureSource
 13
 14from ..connection import DatabaseConnection
 15from ..db import get_connection
 16
 17__all__ = ["CapturedQueries", "CapturedQuery", "capture_queries", "max_queries"]
 18
 19
 20@dataclass(frozen=True)
 21class CapturedQuery:
 22    """One thing the database was asked to do during a `capture_queries` block."""
 23
 24    # The statement as it was sent: `%s` placeholders in place, values apart.
 25    sql: str
 26    # The same statement with the values filled in. An `executemany()` is one
 27    # query here, prefixed with how many times it ran: "3 times: INSERT ...".
 28    sql_with_params: str
 29    # False for BEGIN, COMMIT and ROLLBACK, which the connection issues
 30    # itself. A savepoint is a statement like any other.
 31    is_statement: bool
 32
 33
 34class CapturedQueries(Captured[CapturedQuery]):
 35    """
 36    What the database was asked to do during a `capture_queries` block, in
 37    the order it was asked.
 38    """
 39
 40    def __init__(self) -> None:
 41        super().__init__(helper="capture_queries")
 42
 43    def sql_statements(self, *, table: str | None = None) -> list[str]:
 44        """
 45        The statements as they were sent, with runs of whitespace collapsed
 46        to single spaces — the form to compare against SQL written out in a
 47        test.
 48
 49            with capture_queries() as queries:
 50                cache.set_many({"a": 1, "b": 2})
 51
 52            [statement] = queries.sql_statements()
 53            assert statement.startswith('INSERT INTO "plaincache_cacheditem"')
 54
 55        Savepoints are statements and are here. BEGIN, COMMIT and ROLLBACK
 56        are not: the connection issues those itself.
 57
 58        Pass `table` to keep only the statements that name that table as a
 59        quoted identifier: `table="users_user"` keeps the statements that
 60        contain `"users_user"`.
 61        """
 62        statements = []
 63        for query in self:
 64            if not query.is_statement:
 65                continue
 66            statement = " ".join(query.sql.split())
 67            if table is not None and f'"{table}"' not in statement:
 68                continue
 69            statements.append(statement)
 70        return statements
 71
 72
 73@contextmanager
 74def capture_queries() -> Generator[CapturedQueries]:
 75    """
 76    What the database is asked to do within the block.
 77
 78        with capture_queries() as queries:
 79            list(Article.query.all())
 80
 81        assert len(queries) == 1
 82        assert queries[0].sql.startswith("SELECT")
 83
 84    It records at the connection, so it sees every query whether or not the
 85    query is traced, and whether or not the connection was returned to the
 86    pool and taken out again along the way, as it is by a request outside a
 87    transaction.
 88    """
 89    conn = get_connection()
 90    previous = conn.force_debug_cursor
 91    conn.force_debug_cursor = True
 92    captured = CapturedQueries()
 93    try:
 94        with _query_source_of(conn).capturing_into(captured):
 95            yield captured
 96    finally:
 97        conn.force_debug_cursor = previous
 98
 99
100# Every capture on a connection reads that connection's query log, so each
101# connection has one source, kept for as long as the connection is.
102_query_sources: WeakKeyDictionary[DatabaseConnection, CaptureSource[CapturedQuery]] = (
103    WeakKeyDictionary()
104)
105
106
107def _query_source_of(conn: DatabaseConnection) -> CaptureSource[CapturedQuery]:
108    source = _query_sources.get(conn)
109    if source is None:
110        # The source holds the connection's log, not the connection, so it
111        # doesn't keep a closed connection from being let go.
112        source = CaptureSource(
113            read=partial(_queries_in, conn.captured_queries_log),
114            clear=conn.captured_queries_log.clear,
115        )
116        _query_sources[conn] = source
117    return source
118
119
120def _queries_in(queries_log: list[dict[str, Any]]) -> list[CapturedQuery]:
121    return [
122        CapturedQuery(
123            sql=entry.get("sql_as_sent", entry["sql"]),
124            sql_with_params=entry["sql"],
125            is_statement="sql_as_sent" in entry,
126        )
127        for entry in queries_log
128    ]
129
130
131@contextmanager
132def max_queries(count: int) -> Generator[None]:
133    """
134    Fail if more than `count` database queries execute within the block.
135
136        with max_queries(5):
137            client.get("/dashboard/")
138
139    A query budget is a contract, checked on every run.
140    """
141    with capture_queries() as queries:
142        yield
143    executed = len(queries)
144    if executed > count:
145        sql_lines = "\n".join(f"  {query.sql_with_params}" for query in queries)
146        raise AssertionError(
147            f"Expected at most {count} queries, {executed} were executed:\n{sql_lines}"
148        )