v0.165.0
  1"""Replace a package's migration history with one baseline.
  2
  3The baseline is the file the runtime already understands (see
  4`Migration.supersedes`): the package's schema at its leaf as `CreateModel`
  5operations, the leaf's name as adoption evidence, every deleted name so
  6dependencies on them still resolve. `plan_reset` builds it and every check
  7runs before a file is touched; the CLI writes and deletes.
  8"""
  9
 10import sys
 11from collections.abc import Sequence
 12from dataclasses import dataclass
 13from pathlib import Path
 14from typing import TYPE_CHECKING
 15
 16from plain.postgres.fields.related import ManyToManyField, RelatedField
 17from plain.postgres.options import CONVERGENCE_OPTIONS
 18
 19from . import operations
 20from .autodetector import (
 21    MigrationAutodetector,
 22    describe_changes,
 23    detect_model_changes,
 24    package_creation_operations,
 25)
 26from .exceptions import (
 27    BadMigrationError,
 28    CircularDependencyError,
 29    MigrationSchemaError,
 30)
 31from .loader import MigrationLoader
 32from .migration import Migration
 33from .state import ProjectState
 34from .utils import resolve_relation
 35
 36if TYPE_CHECKING:
 37    from .operations.base import Operation
 38
 39# What the autodetector can regenerate from model state. Anything else in the
 40# deleted history has an effect a baseline cannot carry.
 41REGENERABLE_OPERATIONS = (
 42    operations.CreateModel,
 43    operations.DeleteModel,
 44    operations.RenameModel,
 45    operations.AlterModelOptions,
 46    operations.AlterModelTable,
 47    operations.AddField,
 48    operations.RemoveField,
 49    operations.AlterField,
 50    operations.RenameField,
 51)
 52
 53
 54@dataclass
 55class ResetPlan:
 56    baseline: Migration
 57    migrations_dir: Path
 58    delete: list[Path]
 59
 60    @property
 61    def package_label(self) -> str:
 62        return self.baseline.package_label
 63
 64    @property
 65    def sentinel(self) -> str:
 66        assert self.baseline.supersedes is not None
 67        return self.baseline.supersedes
 68
 69
 70def plan_reset(
 71    loader: MigrationLoader, package_label: str, *, shipped_in: str = ""
 72) -> ResetPlan:
 73    """Build the baseline that would replace `package_label`'s history.
 74
 75    Raises `BadMigrationError` for anything that makes the reset unsafe.
 76    """
 77    assert loader.disk_migrations is not None
 78    on_disk = {
 79        name: migration
 80        for (label, name), migration in loader.disk_migrations.items()
 81        if label == package_label
 82    }
 83    if package_label not in loader.migrated_packages or not on_disk:
 84        raise BadMigrationError(f"`{package_label}` has no migrations to reset.")
 85
 86    leaves = loader.graph.leaf_nodes(package_label)
 87    if len(leaves) != 1:
 88        raise BadMigrationError(
 89            f"`{package_label}` has {len(leaves)} leaf migrations "
 90            f"({', '.join(name for _, name in leaves)}); a reset needs one. Give "
 91            "one a dependency on the other so the package ends in a single "
 92            "migration, then reset."
 93        )
 94    sentinel = leaves[0][1]
 95
 96    previous = loader.baselines.get(package_label)
 97    if previous is not None and not previous.shipped_in:
 98        raise BadMigrationError(
 99            f"`{package_label}` already has a baseline ({previous.name}) that no "
100            "release has shipped (`shipped_in` is empty). Superseding it would strand "
101            f"every database still at {previous.supersedes}. Once that baseline has "
102            "shipped everywhere, set its `shipped_in` to the version that shipped it and "
103            "reset again; otherwise restore the history it replaced and reset once."
104        )
105
106    unregenerable = [
107        f"{package_label}.{name}: {operation.describe()} (operation {position})"
108        for name, migration in on_disk.items()
109        for position, operation in _unregenerable_operations(migration.operations)
110    ]
111    if unregenerable:
112        raise BadMigrationError(
113            f"The history of `{package_label}` has operations a baseline cannot "
114            "carry - a fresh database would never get their effect:\n  - "
115            + "\n  - ".join(unregenerable)
116            + "\nIf a fresh database can do without it, mark it `skip_on_reset=True`; "
117            "if not, move its effect into a seed first, then mark it. Then reset again."
118        )
119
120    retired = set(on_disk)
121    if previous is not None:
122        assert previous.supersedes is not None
123        retired.update(previous.retired)
124        retired.add(previous.supersedes)
125
126    # Past the leaf, and never a name a database may have recorded.
127    number = (MigrationAutodetector.parse_number(sentinel) or 0) + 1
128    name = f"{number:04d}_baseline"
129    while name in retired:
130        number += 1
131        name = f"{number:04d}_baseline"
132
133    state = loader.project_state()
134    try:
135        baseline_operations = package_creation_operations(state, package_label)
136    except ValueError as e:
137        raise BadMigrationError(
138            f"The models of `{package_label}` cannot be expressed as one migration: {e}"
139        ) from e
140    for operation in baseline_operations:
141        if isinstance(operation, operations.CreateModel):
142            # Replayed history still carries option keys convergence owns now.
143            operation.options = {
144                key: value
145                for key, value in operation.options.items()
146                if key not in CONVERGENCE_OPTIONS
147            }
148
149    baseline_class = type(
150        "Migration",
151        (Migration,),
152        {
153            "operations": baseline_operations,
154            "dependencies": _referenced_dependencies(loader, state, package_label),
155            "supersedes": sentinel,
156            "retired": tuple(sorted(retired)),
157            "shipped_in": shipped_in,
158        },
159    )
160    delete = [_source_path(migration) for migration in on_disk.values()]
161    return ResetPlan(
162        baseline=baseline_class(name, package_label),
163        migrations_dir=delete[0].parent,
164        delete=delete,
165    )
166
167
168def validate_reset(loader: MigrationLoader, plan: ResetPlan) -> None:
169    """Prove the post-reset graph loads, renders, and matches the models.
170
171    Runs the loader's own graph build over what the disk would hold after
172    the reset - the old files gone, the baseline in their place - so
173    baseline registration, dependency resolution, consistency and cycle
174    checks all fire here rather than after the deletion. Then renders the
175    baseline's state (an FK to a model its dependencies don't supply only
176    fails on render) and asks the autodetector for what `create` would
177    write: nothing, or the baseline doesn't reproduce the schema.
178
179    Raises `BadMigrationError` with the reason.
180    """
181    assert loader.disk_migrations is not None
182    baseline_key = (plan.package_label, plan.baseline.name)
183    candidate = {
184        key: migration
185        for key, migration in loader.disk_migrations.items()
186        if key[0] != plan.package_label
187    }
188    candidate[baseline_key] = plan.baseline
189
190    try:
191        validation = loader.with_migrations(candidate)
192        rendered = validation.graph.make_state(
193            nodes=[baseline_key], real_packages=validation.unmigrated_packages
194        )
195        rendered.models_registry.get_models()
196    except BadMigrationError:
197        raise
198    except CircularDependencyError as e:
199        raise BadMigrationError(
200            f"The baseline for `{plan.package_label}` would close a cycle in the "
201            f"migration graph: {e}. A baseline is its package's only root and "
202            "depends on the earliest migration of each package its models "
203            "reference; another package depends on this one before that point. "
204            "Nothing in this package can break the cycle - it cannot be reset "
205            "to a single root."
206        ) from e
207    except Exception as e:
208        raise BadMigrationError(str(e)) from e
209
210    try:
211        pending = detect_model_changes(validation, package_labels={plan.package_label})
212    except MigrationSchemaError as e:
213        raise BadMigrationError(str(e)) from e
214    if pending:
215        raise BadMigrationError(
216            f"The baseline for `{plan.package_label}` does not reproduce its "
217            "models; `create` would still write:\n  "
218            + "\n  ".join(describe_changes(pending))
219        )
220
221
222def _unregenerable_operations(
223    ops: Sequence[Operation], *, database_only: bool = False
224) -> list[tuple[int, Operation]]:
225    """(position, operation) for each effect the baseline cannot carry.
226
227    The baseline is regenerated from project state, so an operation counts
228    only if it also moves state. Inside `SeparateDatabaseAndState`, the
229    database side does not - every operation there is unregenerable unless
230    marked - while the state side is ordinary.
231    """
232    found: list[tuple[int, Operation]] = []
233    for position, operation in enumerate(ops):
234        if isinstance(operation, operations.SeparateDatabaseAndState):
235            nested = _unregenerable_operations(
236                operation.database_operations, database_only=True
237            ) + _unregenerable_operations(operation.state_operations)
238            found.extend((position, op) for _, op in nested)
239        elif operation.skip_on_reset:
240            continue
241        elif database_only or not isinstance(operation, REGENERABLE_OPERATIONS):
242            found.append((position, operation))
243    return found
244
245
246def _referenced_dependencies(
247    loader: MigrationLoader, state: ProjectState, package_label: str
248) -> list[tuple[str, str]]:
249    """One dependency per other package the baseline's models reference.
250
251    For each, the earliest migration of that package at which every model
252    referenced exists under its current name - enough for a fresh database
253    to render the baseline, and nothing more: every extra edge is one more
254    way for a reset to close a cycle.
255    """
256    referenced: dict[str, set[tuple[str, str]]] = {}
257    for (label, model_name), model_state in state.models.items():
258        if label != package_label:
259            continue
260        for model_field in model_state.fields.values():
261            if not isinstance(model_field, RelatedField):
262                continue
263            targets = [model_field.remote_field.model_ref]
264            if isinstance(model_field, ManyToManyField) and isinstance(
265                model_field.remote_field.through_ref, str
266            ):
267                targets.append(model_field.remote_field.through_ref)
268            for target in targets:
269                other = resolve_relation(target, label, model_name)
270                if other[0] != package_label:
271                    referenced.setdefault(other[0], set()).add(other)
272        for base in model_state.bases:
273            if isinstance(base, str) and "." in base:
274                other = resolve_relation(base)
275                if other[0] != package_label:
276                    referenced.setdefault(other[0], set()).add(other)
277
278    dependencies: list[tuple[str, str]] = []
279    for other_label in sorted(referenced):
280        if other_label in loader.unmigrated_packages:
281            continue
282        needed = referenced[other_label]
283        # One forward replay of that package's history, checked after each
284        # of its own nodes.
285        replayed = ProjectState(real_packages=loader.unmigrated_packages)
286        for node in _plan_for_package(loader, other_label):
287            migration = loader.graph.nodes[node]
288            assert migration is not None
289            replayed = migration.mutate_state(replayed, preserve=False)
290            if node[0] == other_label and needed <= set(replayed.models):
291                dependencies.append(node)
292                break
293        else:
294            raise BadMigrationError(
295                f"`{package_label}` references "
296                f"{', '.join(sorted(name for _, name in needed))} in "
297                f"`{other_label}`, which no migration of `{other_label}` creates."
298            )
299    return dependencies
300
301
302def _plan_for_package(
303    loader: MigrationLoader, package_label: str
304) -> list[tuple[str, str]]:
305    """Every node the package's leaves need, in application order."""
306    ordered: list[tuple[str, str]] = []
307    for leaf in loader.graph.leaf_nodes(package_label):
308        for node in loader.graph.forwards_plan(leaf):
309            if node not in ordered:
310                ordered.append(node)
311    return ordered
312
313
314def _source_path(migration: Migration) -> Path:
315    module = sys.modules[type(migration).__module__]
316    assert module.__file__ is not None
317    return Path(module.__file__)