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)