Plain is headed towards 1.0! Subscribe for development updates →

  1import re
  2
  3from plain.models.migrations.utils import get_migration_name_timestamp
  4from plain.models.transaction import atomic
  5
  6from .exceptions import IrreversibleError
  7
  8
  9class Migration:
 10    """
 11    The base class for all migrations.
 12
 13    Migration files will import this from plain.models.migrations.Migration
 14    and subclass it as a class called Migration. It will have one or more
 15    of the following attributes:
 16
 17     - operations: A list of Operation instances, probably from
 18       plain.models.migrations.operations
 19     - dependencies: A list of tuples of (app_path, migration_name)
 20     - run_before: A list of tuples of (app_path, migration_name)
 21     - replaces: A list of migration_names
 22
 23    Note that all migrations come out of migrations and into the Loader or
 24    Graph as instances, having been initialized with their app label and name.
 25    """
 26
 27    # Operations to apply during this migration, in order.
 28    operations = []
 29
 30    # Other migrations that should be run before this migration.
 31    # Should be a list of (app, migration_name).
 32    dependencies = []
 33
 34    # Other migrations that should be run after this one (i.e. have
 35    # this migration added to their dependencies). Useful to make third-party
 36    # packages' migrations run after your AUTH_USER replacement, for example.
 37    run_before = []
 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    replaces = []
 43
 44    # Is this an initial migration? Initial migrations are skipped on
 45    # --fake-initial if the table or fields already exist. If None, check if
 46    # the migration has any dependencies to determine if there are dependencies
 47    # to tell if db introspection needs to be done. If True, always perform
 48    # introspection. If False, never perform introspection.
 49    initial = None
 50
 51    # Whether to wrap the whole migration in a transaction. Only has an effect
 52    # on database backends which support transactional DDL.
 53    atomic = True
 54
 55    def __init__(self, name, package_label):
 56        self.name = name
 57        self.package_label = package_label
 58        # Copy dependencies & other attrs as we might mutate them at runtime
 59        self.operations = list(self.__class__.operations)
 60        self.dependencies = list(self.__class__.dependencies)
 61        self.run_before = list(self.__class__.run_before)
 62        self.replaces = list(self.__class__.replaces)
 63
 64    def __eq__(self, other):
 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):
 72        return f"<Migration {self.package_label}.{self.name}>"
 73
 74    def __str__(self):
 75        return f"{self.package_label}.{self.name}"
 76
 77    def __hash__(self):
 78        return hash(f"{self.package_label}.{self.name}")
 79
 80    def mutate_state(self, project_state, preserve=True):
 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(self, project_state, schema_editor, collect_sql=False):
 95        """
 96        Take a project_state representing all migrations prior to this one
 97        and a schema_editor for a live database and apply the migration
 98        in a forwards order.
 99
100        Return the resulting project state for efficient reuse by following
101        Migrations.
102        """
103        for operation in self.operations:
104            # If this operation cannot be represented as SQL, place a comment
105            # there instead
106            if collect_sql:
107                schema_editor.collected_sql.append("--")
108                schema_editor.collected_sql.append("-- %s" % operation.describe())
109                schema_editor.collected_sql.append("--")
110                if not operation.reduces_to_sql:
111                    schema_editor.collected_sql.append(
112                        "-- THIS OPERATION CANNOT BE WRITTEN AS SQL"
113                    )
114                    continue
115                collected_sql_before = len(schema_editor.collected_sql)
116            # Save the state before the operation has run
117            old_state = project_state.clone()
118            operation.state_forwards(self.package_label, project_state)
119            # Run the operation
120            atomic_operation = operation.atomic or (
121                self.atomic and operation.atomic is not False
122            )
123            if not schema_editor.atomic_migration and atomic_operation:
124                # Force a transaction on a non-transactional-DDL backend or an
125                # atomic operation inside a non-atomic migration.
126                with atomic(schema_editor.connection.alias):
127                    operation.database_forwards(
128                        self.package_label, schema_editor, old_state, project_state
129                    )
130            else:
131                # Normal behaviour
132                operation.database_forwards(
133                    self.package_label, schema_editor, old_state, project_state
134                )
135            if collect_sql and collected_sql_before == len(schema_editor.collected_sql):
136                schema_editor.collected_sql.append("-- (no-op)")
137        return project_state
138
139    def unapply(self, project_state, schema_editor, collect_sql=False):
140        """
141        Take a project_state representing all migrations prior to this one
142        and a schema_editor for a live database and apply the migration
143        in a reverse order.
144
145        The backwards migration process consists of two phases:
146
147        1. The intermediate states from right before the first until right
148           after the last operation inside this migration are preserved.
149        2. The operations are applied in reverse order using the states
150           recorded in step 1.
151        """
152        # Construct all the intermediate states we need for a reverse migration
153        to_run = []
154        new_state = project_state
155        # Phase 1
156        for operation in self.operations:
157            # If it's irreversible, error out
158            if not operation.reversible:
159                raise IrreversibleError(
160                    f"Operation {operation} in {self} is not reversible"
161                )
162            # Preserve new state from previous run to not tamper the same state
163            # over all operations
164            new_state = new_state.clone()
165            old_state = new_state.clone()
166            operation.state_forwards(self.package_label, new_state)
167            to_run.insert(0, (operation, old_state, new_state))
168
169        # Phase 2
170        for operation, to_state, from_state in to_run:
171            if collect_sql:
172                schema_editor.collected_sql.append("--")
173                schema_editor.collected_sql.append("-- %s" % operation.describe())
174                schema_editor.collected_sql.append("--")
175                if not operation.reduces_to_sql:
176                    schema_editor.collected_sql.append(
177                        "-- THIS OPERATION CANNOT BE WRITTEN AS SQL"
178                    )
179                    continue
180                collected_sql_before = len(schema_editor.collected_sql)
181            atomic_operation = operation.atomic or (
182                self.atomic and operation.atomic is not False
183            )
184            if not schema_editor.atomic_migration and atomic_operation:
185                # Force a transaction on a non-transactional-DDL backend or an
186                # atomic operation inside a non-atomic migration.
187                with atomic(schema_editor.connection.alias):
188                    operation.database_backwards(
189                        self.package_label, schema_editor, from_state, to_state
190                    )
191            else:
192                # Normal behaviour
193                operation.database_backwards(
194                    self.package_label, schema_editor, from_state, to_state
195                )
196            if collect_sql and collected_sql_before == len(schema_editor.collected_sql):
197                schema_editor.collected_sql.append("-- (no-op)")
198        return project_state
199
200    def suggest_name(self):
201        """
202        Suggest a name for the operations this migration might represent. Names
203        are not guaranteed to be unique, but put some effort into the fallback
204        name to avoid VCS conflicts if possible.
205        """
206        if self.initial:
207            return "initial"
208
209        raw_fragments = [op.migration_name_fragment for op in self.operations]
210        fragments = [re.sub(r"\W+", "_", name) for name in raw_fragments if name]
211
212        if not fragments or len(fragments) != len(self.operations):
213            return "auto_%s" % get_migration_name_timestamp()
214
215        name = fragments[0]
216        for fragment in fragments[1:]:
217            new_name = f"{name}_{fragment}"
218            if len(new_name) > 52:
219                name = f"{name}_and_more"
220                break
221            name = new_name
222        return name
223
224
225class SwappableTuple(tuple):
226    """
227    Subclass of tuple so Plain can tell this was originally a swappable
228    dependency when it reads the migration file.
229    """
230
231    def __new__(cls, value, setting):
232        self = tuple.__new__(cls, value)
233        self.setting = setting
234        return self
235
236
237def swappable_dependency(value):
238    """Turn a setting value into a dependency."""
239    return SwappableTuple((value.split(".", 1)[0], "__first__"), value)