1from __future__ import annotations
2
3import re
4from collections.abc import Callable
5from typing import TYPE_CHECKING, Any
6
7from plain.postgres.migrations.utils import get_migration_name_timestamp
8from plain.postgres.transaction import atomic
9
10if TYPE_CHECKING:
11 from plain.postgres.migrations.state import ProjectState
12 from plain.postgres.schema import DatabaseSchemaEditor
13
14
15class Migration:
16 """
17 The base class for all migrations.
18
19 Migration files will import this from plain.postgres.migrations.Migration
20 and subclass it as a class called Migration. It will have one or more
21 of the following attributes:
22
23 - operations: A list of Operation instances, probably from
24 plain.postgres.migrations.operations
25 - dependencies: A list of tuples of (app_path, migration_name)
26 - replaces: A list of migration_names
27
28 Note that all migrations come out of migrations and into the Loader or
29 Graph as instances, having been initialized with their app label and name.
30 """
31
32 # Operations to apply during this migration, in order.
33 operations: list[Any] = []
34
35 # Other migrations that should be run before this migration.
36 # Should be a list of (app, migration_name).
37 dependencies: list[tuple[str, str]] = []
38
39 # Migration names in this app that this migration replaces. If this is
40 # non-empty, this migration will only be applied if all these migrations
41 # are not applied.
42 # Note: Despite the comment saying "migration names", this is actually a list of tuples
43 # (app_label, migration_name) as used throughout the codebase.
44 replaces: list[tuple[str, str]] = []
45
46 # Is this an initial migration? Initial migrations are skipped on
47 # --fake-initial if the table or fields already exist. If None, check if
48 # the migration has any dependencies to determine if there are dependencies
49 # to tell if db introspection needs to be done. If True, always perform
50 # introspection. If False, never perform introspection.
51 initial: bool | None = None
52
53 # Whether to wrap the whole migration in a transaction.
54 atomic: bool = True
55
56 def __init__(self, name: str, package_label: str) -> None:
57 self.name = name
58 self.package_label = package_label
59 # Copy dependencies & other attrs as we might mutate them at runtime
60 self.operations = list(self.__class__.operations)
61 self.dependencies = list(self.__class__.dependencies)
62 self.replaces = list(self.__class__.replaces)
63
64 def __eq__(self, other: object) -> bool:
65 return (
66 isinstance(other, Migration)
67 and self.name == other.name
68 and self.package_label == other.package_label
69 )
70
71 def __repr__(self) -> str:
72 return f"<Migration {self.package_label}.{self.name}>"
73
74 def __str__(self) -> str:
75 return f"{self.package_label}.{self.name}"
76
77 def __hash__(self) -> int:
78 return hash(f"{self.package_label}.{self.name}")
79
80 def mutate_state(self, project_state: Any, preserve: bool = True) -> Any:
81 """
82 Take a ProjectState and return a new one with the migration's
83 operations applied to it. Preserve the original object state by
84 default and return a mutated state from a copy.
85 """
86 new_state = project_state
87 if preserve:
88 new_state = project_state.clone()
89
90 for operation in self.operations:
91 operation.state_forwards(self.package_label, new_state)
92 return new_state
93
94 def apply(
95 self,
96 project_state: ProjectState,
97 schema_editor: DatabaseSchemaEditor,
98 operation_callback: Callable[..., Any] | None = None,
99 ) -> ProjectState:
100 """
101 Take a project_state representing all migrations prior to this one
102 and a schema_editor for a live database and apply the migration
103 in a forwards order.
104
105 Return the resulting project state for efficient reuse by following
106 Migrations.
107 """
108 for operation in self.operations:
109 # Clear any previous SQL statements before starting this operation
110 schema_editor.executed_sql = []
111
112 if operation_callback:
113 operation_callback("operation_start", operation=operation)
114 # Save the state before the operation has run
115 old_state = project_state.clone()
116 operation.state_forwards(self.package_label, project_state)
117 # Run the operation
118 atomic_operation = operation.atomic or (
119 self.atomic and operation.atomic is not False
120 )
121 if not schema_editor.atomic_migration and atomic_operation:
122 # Force a transaction for an atomic operation inside a non-atomic migration.
123 with atomic():
124 operation.database_forwards(
125 self.package_label, schema_editor, old_state, project_state
126 )
127 else:
128 # Normal behaviour
129 operation.database_forwards(
130 self.package_label, schema_editor, old_state, project_state
131 )
132 if operation_callback:
133 # Pass the accumulated SQL statements for this operation
134 operation_callback(
135 "operation_success",
136 operation=operation,
137 sql_statements=schema_editor.executed_sql,
138 )
139 return project_state
140
141 def suggest_name(self) -> str:
142 """
143 Suggest a name for the operations this migration might represent. Names
144 are not guaranteed to be unique, but put some effort into the fallback
145 name to avoid VCS conflicts if possible.
146 """
147 if self.initial:
148 return "initial"
149
150 raw_fragments = [op.migration_name_fragment for op in self.operations]
151 fragments = [re.sub(r"\W+", "_", name) for name in raw_fragments if name]
152
153 if not fragments or len(fragments) != len(self.operations):
154 return f"auto_{get_migration_name_timestamp()}"
155
156 name = fragments[0]
157 for fragment in fragments[1:]:
158 new_name = f"{name}_{fragment}"
159 if len(new_name) > 52:
160 name = f"{name}_and_more"
161 break
162 name = new_name
163 return name
164
165
166class SettingsTuple(tuple):
167 """
168 Subclass of tuple so Plain can tell this was originally a settings
169 dependency when it reads the migration file.
170 """
171
172 def __new__(cls, value: tuple[str, str], setting: str) -> SettingsTuple:
173 self = tuple.__new__(cls, value)
174 self.setting = setting
175 return self
176
177
178def settings_dependency(value: str) -> SettingsTuple:
179 """Turn a setting value into a dependency."""
180 return SettingsTuple((value.split(".", 1)[0], "__first__"), value)