diff --git a/django/db/migrations/autodetector.py b/django/db/migrations/autodetector.py index 0c2e215fcd..6efc2c1bef 100644 --- a/django/db/migrations/autodetector.py +++ b/django/db/migrations/autodetector.py @@ -1,3 +1,4 @@ +import contextlib import functools import re from collections import defaultdict, namedtuple @@ -5,7 +6,11 @@ from graphlib import TopologicalSorter from itertools import chain +from django_mongodb_backend.indexes import FieldColumn +from django_mongodb_backend.models import EMBEDDED + from django.conf import settings +from django.core.exceptions import FieldDoesNotExist from django.db import models from django.db.migrations import operations from django.db.migrations.migration import Migration @@ -30,6 +35,7 @@ class Type(Enum): REMOVE_ORDER_WRT = 3 ALTER_FOO_TOGETHER = 4 REMOVE_INDEX_OR_CONSTRAINT = 5 + RENAME = 6 @cached_property def model_name_lower(self): @@ -215,6 +221,8 @@ def _detect_changes(self, convert_apps=None, graph=None): self.generate_removed_altered_unique_together() # Generate field operations. self.generate_removed_fields() + self.generate_renamed_embedded_fields() + self.generate_removed_embedded_fields() self.generate_added_fields() self.generate_altered_fields() self.generate_altered_order_with_respect_to() @@ -224,6 +232,10 @@ def _detect_changes(self, convert_apps=None, graph=None): self.generate_altered_constraints() self.generate_altered_db_table() + # Generate embedded field operations. + self.generate_added_embedded_fields() + self.generate_altered_embedded_fields() + self._sort_migrations() self._build_migration_list(graph) self._optimize_migrations() @@ -253,6 +265,82 @@ def _prepare_field_lists(self): for field_name in self.to_state.models[app_label, model_name].fields } + def get_old_embedded_paths(field, name_prefix=None, path_prefix=None): + if hasattr(field, "embedded_model"): + if isinstance(field.embedded_model, str): + model_label = field.embedded_model + else: + model_label = field.embedded_model._meta.label_lower + model_lookup = tuple(model_label.split(".")) + embedded_model = self.from_state.models[ + app_label, + self.renamed_models.get(model_lookup, model_lookup[1]), + ] + for subfield_name, subfield in embedded_model.fields.items(): + if name_prefix: + name = f"{name_prefix}.{subfield.name}" + else: + name = f"{field.name}.{subfield.name}" + subfield_column = subfield.get_attname_column()[1] + if path_prefix: + path = f"{path_prefix}.{subfield_column}" + else: + field_column = field.get_attname_column()[1] + path = f"{field_column}.{subfield_column}" + self.old_embedded_field_keys[(app_label, model_name, name)] = path + # Check for nested embeds. + get_old_embedded_paths(subfield, name, path) + + # New/old embedded field keys + # (app_label, model_name, embedded field name path) + self.old_embedded_field_keys = {} + for app_label, model_name in self.kept_model_keys: + for field_name, field in self.from_state.models[ + app_label, self.renamed_models.get((app_label, model_name), model_name) + ].fields.items(): + try: + if ( + self.from_state.models[app_label, model_name].options.get( + "db_table" + ) + is EMBEDDED + ): + continue + except KeyError: + continue + + get_old_embedded_paths(field) + + def get_new_embedded_paths(field, name_prefix=None, path_prefix=None): + if hasattr(field, "embedded_model"): + embedded_model = self.to_state.models[*field.embedded_model.split(".")] + field_column = field.get_attname_column()[1] + for subfield_name, subfield in embedded_model.fields.items(): + subfield_column = subfield.get_attname_column()[1] + if name_prefix: + name = f"{name_prefix}.{subfield.name}" + else: + name = f"{field.name}.{subfield.name}" + if path_prefix: + path = f"{path_prefix}.{subfield_column}" + else: + field_column = field.get_attname_column()[1] + path = f"{field_column}.{subfield_column}" + self.new_embedded_field_keys[(app_label, model_name, name)] = path + get_new_embedded_paths(subfield, name, path) + + self.new_embedded_field_keys = {} + for app_label, model_name in self.kept_model_keys: + for field_name, field in self.to_state.models[ + app_label, model_name + ].fields.items(): + if ( + self.to_state.models[app_label, model_name].options.get("db_table") + is EMBEDDED + ): + continue + get_new_embedded_paths(field) + def _generate_through_model_map(self): """Through model map generation.""" for app_label, model_name in sorted(self.old_model_keys): @@ -542,6 +630,16 @@ def check_dependency(self, operation, dependency): ) and operation.model_name_lower == dependency.model_name_lower ) + # Field being renamed. + elif ( + dependency.field_name is not None + and dependency.type == OperationDependency.Type.RENAME + ): + return ( + isinstance(operation, operations.RenameField) + and operation.model_name_lower == dependency.model_name_lower + and operation.new_name_lower == dependency.field_name_lower + ) # Unknown dependency. Raise an error. else: raise ValueError("Can't handle dependency %r" % (dependency,)) @@ -1465,6 +1563,243 @@ def create_altered_indexes(self): } ) + def generate_added_embedded_fields(self): + """Make AddEmbeddedField operations.""" + for app_label, model_name, field_name in sorted( + set(self.new_embedded_field_keys) - set(self.old_embedded_field_keys) + ): + # If the embedded field was just added, then there's no need to + # add each subfield. + if "." in field_name: + parent_field_name = field_name.rsplit(".", 1)[0] + if ( + app_label, + model_name, + parent_field_name, + ) not in self.old_field_keys and ( + app_label, + model_name, + parent_field_name, + ) not in self.old_embedded_field_keys: + continue + self._generate_added_embedded_field(app_label, model_name, field_name) + + def _generate_added_embedded_field(self, app_label, model_name, field_name): + from django_mongodb_backend.db.migrations.operations import AddEmbeddedField + + field = self.get_field(self.to_state.models[app_label, model_name], field_name) + # Adding a field always depends at least on its removal. + dependencies = [ + OperationDependency( + app_label, model_name, field_name, OperationDependency.Type.REMOVE + ) + ] + # You can't just add NOT NULL fields with no default or fields + # which don't allow empty strings as default. + time_fields = (models.DateField, models.DateTimeField, models.TimeField) + preserve_default = ( + field.null + or field.has_default() + or (field.blank and field.empty_strings_allowed) + or (isinstance(field, time_fields) and field.auto_now) + ) + if not preserve_default: + field = field.clone() + if isinstance(field, time_fields) and field.auto_now_add: + field.default = self.questioner.ask_auto_now_add_addition( + field_name, model_name + ) + else: + field.default = self.questioner.ask_not_null_addition( + field_name, model_name + ) + if field.unique and field.has_default() and callable(field.default): + self.questioner.ask_unique_callable_default_addition(field_name, model_name) + self.add_operation( + app_label, + AddEmbeddedField( + model_name=model_name, + name=self.new_embedded_field_keys[app_label, model_name, field_name], + field=field, + preserve_default=preserve_default, + ), + dependencies=dependencies, + ) + + def generate_renamed_embedded_fields(self): + """ + Make RenameEmbeddedField operations for renamed embedded leaf fields. + """ + # Build inverse of renamed_fields: (app_label, model_name, old_name) + # -> new_name. + renamed_field_inverse = { + (al, mn, old): new for (al, mn, new), old in self.renamed_fields.items() + } + for app_label, model_name, old_attr_path in sorted( + set(self.old_embedded_field_keys) - set(self.new_embedded_field_keys) + ): + *parents, leaf_field_name = old_attr_path.split(".") + # Navigate the from_state to find the model containing the leaf + # field. + current_app_label = app_label + current_model_name = model_name + for part in parents: + field = self.from_state.models[ + current_app_label, current_model_name + ].get_field(part) + if hasattr(field, "embedded_model"): + label_parts = field.embedded_model.split(".") + if len(label_parts) == 2: + current_app_label, current_model_name = label_parts + else: + current_model_name = label_parts[0] + # Check if the leaf field was renamed. + new_leaf_field_name = renamed_field_inverse.get( + (current_app_label, current_model_name, leaf_field_name) + ) + if new_leaf_field_name is None: + continue + new_attr_path = ".".join([*parents, new_leaf_field_name]) + if ( + app_label, + model_name, + new_attr_path, + ) not in self.new_embedded_field_keys: + continue + self._generate_renamed_embedded_field( + app_label, + model_name, + old_attr_path, + new_attr_path, + current_app_label, + current_model_name, + new_leaf_field_name, + ) + # Remove from both dicts so remove/add generators skip these paths. + del self.old_embedded_field_keys[app_label, model_name, old_attr_path] + del self.new_embedded_field_keys[app_label, model_name, new_attr_path] + + def _generate_renamed_embedded_field( + self, + app_label, + model_name, + old_attr_path, + new_attr_path, + leaf_app_label, + leaf_model_name, + leaf_new_field_name, + ): + from django_mongodb_backend.db.migrations.operations import RenameEmbeddedField + + self.add_operation( + app_label, + RenameEmbeddedField( + model_name=model_name, + old_name=old_attr_path, + new_name=new_attr_path, + ), + dependencies=[ + OperationDependency( + leaf_app_label, + leaf_model_name, + leaf_new_field_name, + OperationDependency.Type.RENAME, + ) + ], + ) + + def generate_removed_embedded_fields(self): + """Make RemoveEmbeddedField operations.""" + for app_label, model_name, field_name in sorted( + set(self.old_embedded_field_keys) - set(self.new_embedded_field_keys) + ): + self._generate_removed_embedded_field(app_label, model_name, field_name) + + def _generate_removed_embedded_field(self, app_label, model_name, field_name): + from django_mongodb_backend.db.migrations.operations import RemoveEmbeddedField + + self.add_operation( + app_label, + RemoveEmbeddedField( + model_name=model_name, + name=field_name, + ), + # Include dependencies such as order_with_respect_to, constraints, + # and any generated fields that may depend on this field. These + # are safely ignored if not present. + dependencies=[ + OperationDependency( + app_label, + model_name, + field_name, + OperationDependency.Type.REMOVE_ORDER_WRT, + ), + OperationDependency( + app_label, + model_name, + field_name, + OperationDependency.Type.ALTER_FOO_TOGETHER, + ), + OperationDependency( + app_label, + model_name, + field_name, + OperationDependency.Type.REMOVE_INDEX_OR_CONSTRAINT, + ), + *self._get_generated_field_dependencies_for_removed_field( + app_label, model_name, field_name + ), + ], + ) + + def generate_altered_embedded_fields(self): + """ + Make AlterEmbeddedField operations when an embedded column path + changes. + """ + common = set(self.old_embedded_field_keys) & set(self.new_embedded_field_keys) + for app_label, model_name, field_name in sorted(common): + old_path = self.old_embedded_field_keys[app_label, model_name, field_name] + new_path = self.new_embedded_field_keys[app_label, model_name, field_name] + if old_path != new_path: + self._generate_altered_embedded_field(app_label, model_name, field_name) + + def _generate_altered_embedded_field(self, app_label, model_name, field_name): + from django_mongodb_backend.db.migrations.operations import AlterEmbeddedField + + field = self.get_field(self.to_state.models[app_label, model_name], field_name) + # Navigate the path to locate the leaf embedded model for the ALTER + # dependency. + *parents, leaf_field_name = field_name.split(".") + current_app_label = app_label + current_model_name = model_name + for part in parents: + parent_field = self.to_state.models[ + current_app_label, current_model_name + ].get_field(part) + if hasattr(parent_field, "embedded_model"): + label_parts = parent_field.embedded_model.split(".") + if len(label_parts) == 2: + current_app_label, current_model_name = label_parts + else: + current_model_name = label_parts[0] + self.add_operation( + app_label, + AlterEmbeddedField( + model_name=model_name, + name=field_name, + field=field, + ), + dependencies=[ + OperationDependency( + current_app_label, + current_model_name, + leaf_field_name, + OperationDependency.Type.ALTER, + ) + ], + ) + def generate_added_indexes(self): for (app_label, model_name), alt_indexes in self.altered_indexes.items(): dependencies = self._get_dependencies_for_model(app_label, model_name) @@ -2064,3 +2399,41 @@ def parse_number(cls, name): if match: return int(match[0]) return None + + def get_field(self, model, field_name): + """ + A version of Model_.meta.get_field() that can retrieve embedded model + fields. + """ + path = [] + base_model = model + *parents, leaf = field_name.split(".") + for i, name in enumerate(parents): + field = model.get_field(name) + path.append(getattr(field, "column", field.get_attname_column()[1])) + # For EmbeddedModelFields, advance to the embedded model and + # continue to loop, searching for the next field. + if hasattr(field, "embedded_model"): + model_lookup = tuple(field.embedded_model.split(".")) + model = self.to_state.models[model_lookup] + # For PolymorphicEmbeddedModelFields, recurse into each embedded + # model until the field is found. + elif models := getattr(field, "embedded_models", None): + for submodel in models: + with contextlib.suppress(FieldDoesNotExist): + subfield = self.get_field( + submodel, ".".join([*parents[i + 1 :], leaf]) + ) + path.extend(subfield.column.split(".")) + return FieldColumn(subfield.field, ".".join(path)) + raise FieldDoesNotExist( + f"The models of field '{'.'.join(parents)}' have no field " + f"named '{leaf}'." + ) + else: + raise FieldDoesNotExist( + f"{base_model.__name__} has no field named '{field_name}'." + ) + # Add the final field. + field = model.get_field(leaf) + return field