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