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__)