v0.165.0
  1"""Decide what to do with a baseline migration: run it, record it, or refuse.
  2
  3A baseline stands in for a package's deleted history (`Migration.supersedes`).
  4The loader computes this once, next to the records it observed, and stores it
  5as `loader.baseline_status`; the planner, preflight, and plain-dev all read
  6that. Nothing here writes.
  7"""
  8
  9from dataclasses import dataclass, field
 10from typing import TYPE_CHECKING, Any
 11
 12from plain.postgres.options import default_db_table
 13
 14from .exceptions import (
 15    MigrationHistoryError,
 16    ResetBoundaryError,
 17    StaleMigrationRecordsError,
 18    UnrecordedTablesError,
 19)
 20
 21if TYPE_CHECKING:
 22    from plain.postgres.connection import DatabaseConnection
 23
 24    from .loader import MigrationLoader
 25    from .migration import Migration
 26
 27
 28@dataclass
 29class BaselineStatus:
 30    # Baselines to record without running, before anything else runs.
 31    adopt: list[Migration] = field(default_factory=list)
 32    # Why the planner must stop, per package. `str(error)` is the operator message.
 33    refusals: list[MigrationHistoryError] = field(default_factory=list)
 34
 35    @property
 36    def adopt_keys(self) -> set[tuple[str, str]]:
 37        return {(b.package_label, b.name) for b in self.adopt}
 38
 39
 40def classify_baselines(
 41    loader: MigrationLoader,
 42    applied: dict[tuple[str, str], Any],
 43    connection: DatabaseConnection,
 44) -> BaselineStatus:
 45    """
 46    For each package that ships a baseline:
 47
 48    - baseline recorded -> nothing to do
 49    - no records for the package, no tables -> the planner runs it
 50    - no records for the package, tables present -> refuse: fake it or drop them
 51    - the superseded migration recorded -> adopt (if the tables are there)
 52    - records but not the superseded migration -> refuse, too old
 53    - records but tables missing -> refuse, stale records
 54    """
 55    status = BaselineStatus()
 56    if not loader.baselines:
 57        return status
 58
 59    recorded_packages = {label for label, _name in applied}
 60    existing_tables: set[str] | None = None
 61
 62    def tables_present(tables: list[str]) -> list[str]:
 63        nonlocal existing_tables
 64        if existing_tables is None:
 65            existing_tables = set(connection.table_names())
 66        return [table for table in tables if table in existing_tables]
 67
 68    for package_label, baseline in loader.baselines.items():
 69        key = (package_label, baseline.name)
 70        if key in applied:
 71            continue
 72        if package_label not in recorded_packages:
 73            present = tables_present(applied_tables(loader, applied, baseline))
 74            if present:
 75                status.refusals.append(
 76                    UnrecordedTablesError(package_label, baseline.name, present)
 77                )
 78            continue
 79        assert baseline.supersedes is not None
 80        if (package_label, baseline.supersedes) not in applied:
 81            status.refusals.append(
 82                ResetBoundaryError(
 83                    package_label, baseline.supersedes, baseline.shipped_in
 84                )
 85            )
 86            continue
 87
 88        tables = applied_tables(loader, applied, baseline)
 89        missing = [t for t in tables if t not in tables_present(tables)]
 90        if missing:
 91            status.refusals.append(
 92                StaleMigrationRecordsError(package_label, missing, tables)
 93            )
 94            continue
 95
 96        status.adopt.append(baseline)
 97
 98    return status
 99
100
101def applied_tables(
102    loader: MigrationLoader, applied: dict[tuple[str, str], Any], baseline: Migration
103) -> list[str]:
104    """The package's tables as this database should have them right now.
105
106    Replays the baseline plus whatever recorded migrations follow it, so a
107    kept migration that renamed a table counts by its new name, and a pending
108    one that hasn't run yet doesn't count at all.
109    """
110    package_label = baseline.package_label
111    nodes = [(package_label, baseline.name)] + [
112        key for key in applied if key[0] == package_label and key in loader.graph.nodes
113    ]
114    state = loader.graph.make_state(
115        nodes=nodes, real_packages=loader.unmigrated_packages
116    )
117    return [
118        model_state.options.get("db_table")
119        or default_db_table(package_label, model_state.name)
120        for (label, _name), model_state in state.models.items()
121        if label == package_label
122    ]