v0.166.0
  1"""
  2Database test lifecycle, registered under the `plain.testing` entry point.
  3
  4Every test runs against a dedicated test database (created once per run,
  5as a clone of a migrated and converged template that stays between runs)
  6inside a transaction that rolls back afterward.
  7Tests tagged with `@isolated_db` get their own separately-created database
  8for the duration of the test instead of a rolled-back transaction — for
  9DDL-heavy tests (migrations, convergence) that can't run inside a
 10transaction that never commits.
 11
 12The databases are the run's own, and while one is in use it is the
 13configured database for the whole process: see `database.py`.
 14
 15The rolled-back tests share one connection, opened by the first of them.
 16The rollback undoes everything a test did in its transaction. What belongs
 17to the session and not to a transaction carries over to the next test: a
 18session-level advisory lock, a `LISTEN`, a server-side prepared statement.
 19A test that needs a session of its own is `@isolated_db`.
 20"""
 21
 22from collections.abc import Generator
 23from contextlib import ExitStack, contextmanager
 24
 25from plain.runtime import settings
 26from plain.testing import CollectedTest, TestLifecycle
 27
 28from .. import transaction
 29from ..base import Model
 30from ..db import get_connection
 31from ..otel import suppress_db_tracing
 32from ..query import QuerySet
 33from .database import RunDatabases, use_test_database
 34from .decorators import ISOLATED_DB_TAG
 35from .schema import describe_schema
 36
 37# A model instance that prints in less than this is printed on one line.
 38_ONE_LINE = 80
 39
 40
 41def describe_model_instance(instance: Model) -> str:
 42    """
 43    A model instance with its fields: `Widget(id=1, name='bolt', size='m')`,
 44    or one field to a line when that is long. Its `repr` is its class and
 45    its id.
 46
 47    A field that was deferred is read from where a loaded one is kept, so
 48    it is reported as not loaded and is not fetched.
 49
 50    A field whose type says its value is a secret (`value_is_secret`: an
 51    encrypted field, a password) is printed as withheld. One with nothing
 52    in it says so, since that gives nothing away.
 53    """
 54    fields = []
 55    for field in instance._model_meta.fields:
 56        if field.name not in instance.__dict__:
 57            fields.append(f"{field.name}=<not loaded>")
 58            continue
 59        value = instance.__dict__[field.name]
 60        if field.value_is_secret and value not in (None, ""):
 61            fields.append(f"{field.name}=<withheld>")
 62        else:
 63            fields.append(f"{field.name}={value!r}")
 64
 65    name = type(instance).__name__
 66    on_one_line = f"{name}({', '.join(fields)})"
 67    if len(on_one_line) <= _ONE_LINE:
 68        return on_one_line
 69    return "\n".join([f"{name}(", *(f"    {field}," for field in fields), ")"])
 70
 71
 72def describe_queryset(queryset: QuerySet) -> str:
 73    """
 74    A queryset by what it holds. One that has run prints its rows. One that
 75    hasn't prints the SQL it would run, and doesn't run it.
 76    """
 77    name = f"{type(queryset).__name__} of {queryset.model.__name__}"
 78    rows = queryset._result_cache
 79    if rows is not None:
 80        count = "1 row" if len(rows) == 1 else f"{len(rows):,} rows"
 81        return f"<{name}, {count}: {rows!r}>"
 82    try:
 83        # Compiled from a copy. Compiling settles a query's joins and
 84        # aliases, which is a change to the test's own queryset.
 85        sql, params = queryset._chain().sql_query.sql_with_params()
 86    except Exception:
 87        return f"<{name}, not run>"
 88    return f"<{name}, not run: {sql} with {params!r}>"
 89
 90
 91class PostgresTestLifecycle(TestLifecycle):
 92    required_package = "plain.postgres"
 93
 94    def __init__(self) -> None:
 95        # Holds the run's claim and its shared database, from setup to teardown.
 96        self._test_database = ExitStack()
 97        self._run: RunDatabases | None = None
 98
 99    def setup_worker(self) -> None:
100        run = RunDatabases(
101            runtime_url=str(settings.POSTGRES_URL),
102            management_url=str(settings.POSTGRES_MANAGEMENT_URL),
103        )
104        self._run = run
105        with suppress_db_tracing():
106            run.claim(schema=describe_schema(server_version=run.server_version()))
107            self._test_database.callback(run.release)
108            self._test_database.enter_context(
109                use_test_database(run=run, name=run.shared_name, verbosity=0)
110            )
111
112    def teardown_worker(self) -> None:
113        with suppress_db_tracing():
114            # Closes the connection the tests shared, drops the database,
115            # then gives up the run's claim.
116            self._test_database.close()
117
118    def describe_setup(self) -> tuple[tuple[str, float], ...]:
119        # Whether the template was built by this run or cloned, and how long
120        # each took.
121        assert self._run is not None
122        return tuple(self._run.setup_parts)
123
124    @contextmanager
125    def around_test(self, test: CollectedTest) -> Generator[None]:
126        if ISOLATED_DB_TAG in test.tags:
127            yield from self._run_in_isolated_database(test)
128        else:
129            yield from self._run_in_rolled_back_transaction()
130
131    def describe_value(self, value: object) -> str | None:
132        if isinstance(value, Model):
133            return describe_model_instance(value)
134        if isinstance(value, QuerySet):
135            return describe_queryset(value)
136        return None
137
138    def _run_in_rolled_back_transaction(self) -> Generator[None]:
139        with suppress_db_tracing():
140            atomic = transaction.atomic()
141            atomic._from_testcase = True
142            atomic.__enter__()
143            # The transaction isn't open yet: the driver begins it with the
144            # first statement. Until then, code that opens psycopg's own
145            # `connection.transaction()` finds none, begins one itself, and
146            # commits it, and what it did outlives the test. So send the
147            # first statement here. The connection was the last test's too,
148            # and a session-level advisory lock is the session's, which a
149            # rollback doesn't release: the statement gives back any that
150            # test left held.
151            with get_connection().cursor() as cursor:
152                cursor.execute("SELECT pg_advisory_unlock_all()")
153
154        try:
155            yield
156        finally:
157            with suppress_db_tracing():
158                # Constraints are never deferred (foreign keys are NOT
159                # DEFERRABLE), so there is nothing to check before rolling
160                # back — the database has already rejected any violation.
161                get_connection().set_rollback(True)
162                atomic.__exit__(None, None, None)
163                # The connection stays open for the next test. Opening one
164                # costs a hundred times what the rollback does.
165
166    def _run_in_isolated_database(self, test: CollectedTest) -> Generator[None]:
167        run = self._run
168        assert run is not None
169        test_name = test.id.rpartition("::")[2]
170
171        ctx = use_test_database(run=run, name=run.isolated_name(test_name), verbosity=0)
172        with suppress_db_tracing():
173            ctx.__enter__()
174        try:
175            yield
176        finally:
177            with suppress_db_tracing():
178                ctx.__exit__(None, None, None)