v0.166.0
  1"""
  2What decides a test database's schema, as one digest.
  3
  4A run's test database is built from nothing: created, migrated, converged.
  5Every run of the same code builds the same one, so the first run keeps its
  6build as a template for the rest to clone (`database.py`), and one template
  7is told from another by what it was built from: the migration files, and
  8the models as convergence reads them. A change to either is a different
  9digest, and a different template.
 10"""
 11
 12import hashlib
 13import sys
 14from dataclasses import dataclass
 15
 16import psycopg
 17from plain.postgres import __version__ as plain_postgres_version
 18from plain.postgres.database_url import DatabaseConfig
 19from plain.postgres.databases import maintenance_cursor
 20from plain.postgres.migrations.loader import MigrationLoader
 21from plain.postgres.migrations.migration import Migration
 22from plain.postgres.migrations.serializer import serializer_factory
 23from plain.postgres.migrations.state import ModelState
 24from plain.postgres.options import CONVERGENCE_OPTIONS
 25from plain.postgres.registry import models_registry
 26
 27
 28@dataclass(frozen=True)
 29class SchemaDigest:
 30    """What a test database's schema is built from, as one hash."""
 31
 32    hash: str  # sha256, in hex
 33    migrations: int  # how many migration files go into it
 34
 35
 36def describe_schema(*, server_version: str) -> SchemaDigest:
 37    """
 38    A digest of everything a test database is built from: the versions of
 39    plain.postgres and psycopg that build it and of the Postgres server
 40    that holds it (`server_version`, from `read_server_version`), every
 41    migration file's contents, and every model's fields and options,
 42    serialized the way a migration file would write them, so that a
 43    callable default is named and not printed with its address.
 44
 45    The versions are in it because a new one can change what a build
 46    produces with no file in the project changing.
 47    """
 48    digest = hashlib.sha256()
 49    digest.update(f"plain.postgres {plain_postgres_version}\n".encode())
 50    digest.update(f"psycopg {psycopg.__version__}\n".encode())
 51    digest.update(f"postgres {server_version}\n".encode())
 52
 53    loader = MigrationLoader(None, ignore_no_migrations=True)
 54    assert loader.disk_migrations is not None
 55    for (label, name), migration in sorted(loader.disk_migrations.items()):
 56        digest.update(f"migration {label} {name}\n".encode())
 57        digest.update(_source_of(migration))
 58
 59    models = sorted(
 60        models_registry.get_models(), key=lambda model: model.model_options.label_lower
 61    )
 62    for model in models:
 63        state = ModelState.from_model(model)
 64        digest.update(f"model {state.package_label} {state.name}\n".encode())
 65        for field_name, field in state.fields.items():
 66            digest.update(f"  {field_name} = {_serialized(field)}\n".encode())
 67        for option, value in sorted(state.options.items()):
 68            digest.update(f"  {option} = {_serialized(value)}\n".encode())
 69        # What migrations leave out and convergence reads from the model:
 70        # indexes, constraints, storage parameters.
 71        for option in CONVERGENCE_OPTIONS:
 72            value = getattr(model.model_options, option)
 73            digest.update(f"  {option} = {_serialized(value)}\n".encode())
 74
 75    return SchemaDigest(hash=digest.hexdigest(), migrations=len(loader.disk_migrations))
 76
 77
 78def read_server_version(connection: psycopg.Connection) -> str:
 79    """The Postgres server's version, as `SHOW server_version` gives it."""
 80    row = connection.execute("SHOW server_version").fetchone()
 81    assert row is not None
 82    return row[0]
 83
 84
 85def server_version_of(config: DatabaseConfig) -> str:
 86    """The version of the server `config` connects to."""
 87    with maintenance_cursor(config) as cursor:
 88        row = cursor.execute("SHOW server_version").fetchone()
 89        assert row is not None
 90        return row[0]
 91
 92
 93def _source_of(migration: Migration) -> bytes:
 94    module = sys.modules[type(migration).__module__]
 95    assert module.__file__ is not None
 96    with open(module.__file__, "rb") as file:
 97        return file.read()
 98
 99
100def _serialized(value: object) -> str:
101    try:
102        text, _ = serializer_factory(value).serialize()
103    except ValueError:
104        # Not something a migration file could hold. Its repr tells one
105        # value from another; when it holds an address, the digest is
106        # different in every process, and a template is built every run
107        # and never wrong.
108        text = repr(value)
109    return text