from contextlib import contextmanager from unittest import mock import django from django.db.migrations import ( AddField, AlterField, CreateModel, DeleteModel, RemoveField, RenameField, ) from django.db.migrations.autodetector import MigrationAutodetector from django.db.migrations.operations.fields import FieldOperation from psqlextra.models import ( PostgresMaterializedViewModel, PostgresPartitionedModel, PostgresViewModel, ) from psqlextra.types import PostgresPartitioningMethod from . import operations from .state import ( PostgresMaterializedViewModelState, PostgresPartitionedModelState, PostgresViewModelState, ) # original `MigrationAutodetector.add_operation` # function, saved here so the patched version can # call the original add_operation = MigrationAutodetector.add_operation class AddOperationHandler: """Handler for when operations are being added to a new migration. This is where we intercept operations such as :see:CreateModel to replace it with our own. """ def __init__(self, autodetector, app_label, args, kwargs): self.autodetector = autodetector self.app_label = app_label self.args = args self.kwargs = kwargs def add(self, operation): """Adds the specified operation to the list of operations to execute in the migration.""" return add_operation( self.autodetector, self.app_label, operation, *self.args, **self.kwargs, ) def add_field(self, operation: AddField): """Adds the specified :see:AddField operation to the list of operations to execute in the migration.""" return self._transform_view_field_operations(operation) def remove_field(self, operation: RemoveField): """Adds the specified :see:RemoveField operation to the list of operations to execute in the migration.""" return self._transform_view_field_operations(operation) def alter_field(self, operation: AlterField): """Adds the specified :see:AlterField operation to the list of operations to execute in the migration.""" return self._transform_view_field_operations(operation) def rename_field(self, operation: RenameField): """Adds the specified :see:RenameField operation to the list of operations to execute in the migration.""" return self._transform_view_field_operations(operation) def _transform_view_field_operations(self, operation: FieldOperation): """Transforms operations on fields on a (materialized) view into state only operations. One cannot add/remove/delete fields on a (materialized) view, however, we do want Django's migration system to keep track of these kind of changes to the model. The :see:ApplyState operation just tells Django the operation was applied without actually applying it. """ if django.VERSION >= (4, 0): model_identifier = (self.app_label, operation.model_name.lower()) model_state = ( self.autodetector.to_state.models.get(model_identifier) or self.autodetector.from_state.models[model_identifier] ) if isinstance(model_state, PostgresViewModelState): return self.add( operations.ApplyState(state_operation=operation) ) else: model = self.autodetector.new_apps.get_model( self.app_label, operation.model_name ) if issubclass(model, PostgresViewModel): return self.add( operations.ApplyState(state_operation=operation) ) return self.add(operation) def add_create_model(self, operation: CreateModel): """Adds the specified :see:CreateModel operation to the list of operations to execute in the migration.""" if django.VERSION >= (4, 0): model_state = self.autodetector.to_state.models[ self.app_label, operation.name.lower() ] if isinstance(model_state, PostgresPartitionedModelState): return self.add_create_partitioned_model(operation) elif isinstance(model_state, PostgresMaterializedViewModelState): return self.add_create_materialized_view_model(operation) elif isinstance(model_state, PostgresViewModelState): return self.add_create_view_model(operation) else: model = self.autodetector.new_apps.get_model( self.app_label, operation.name ) if issubclass(model, PostgresPartitionedModel): return self.add_create_partitioned_model(operation) elif issubclass(model, PostgresMaterializedViewModel): return self.add_create_materialized_view_model(operation) elif issubclass(model, PostgresViewModel): return self.add_create_view_model(operation) return self.add(operation) def add_delete_model(self, operation: DeleteModel): """Adds the specified :see:Deletemodel operation to the list of operations to execute in the migration.""" if django.VERSION >= (4, 0): model_state = self.autodetector.from_state.models[ self.app_label, operation.name.lower() ] if isinstance(model_state, PostgresPartitionedModelState): return self.add_delete_partitioned_model(operation) elif isinstance(model_state, PostgresMaterializedViewModelState): return self.add_delete_materialized_view_model(operation) elif isinstance(model_state, PostgresViewModelState): return self.add_delete_view_model(operation) else: model = self.autodetector.old_apps.get_model( self.app_label, operation.name ) if issubclass(model, PostgresPartitionedModel): return self.add_delete_partitioned_model(operation) elif issubclass(model, PostgresMaterializedViewModel): return self.add_delete_materialized_view_model(operation) elif issubclass(model, PostgresViewModel): return self.add_delete_view_model(operation) return self.add(operation) def add_create_partitioned_model(self, operation: CreateModel): """Adds a :see:PostgresCreatePartitionedModel operation to the list of operations to execute in the migration.""" if django.VERSION >= (4, 0): model_state = self.autodetector.to_state.models[ self.app_label, operation.name.lower() ] partitioning_options = model_state.partitioning_options else: model = self.autodetector.new_apps.get_model( self.app_label, operation.name ) partitioning_options = model._partitioning_meta.original_attrs _, args, kwargs = operation.deconstruct() if partitioning_options["method"] != PostgresPartitioningMethod.HASH: self.add( operations.PostgresAddDefaultPartition( model_name=operation.name, name="default" ) ) partitioned_kwargs = { **kwargs, "partitioning_options": partitioning_options, } self.add( operations.PostgresCreatePartitionedModel( *args, **partitioned_kwargs, ) ) def add_delete_partitioned_model(self, operation: DeleteModel): """Adds a :see:PostgresDeletePartitionedModel operation to the list of operations to execute in the migration.""" _, args, kwargs = operation.deconstruct() return self.add( operations.PostgresDeletePartitionedModel(*args, **kwargs) ) def add_create_view_model(self, operation: CreateModel): """Adds a :see:PostgresCreateViewModel operation to the list of operations to execute in the migration.""" if django.VERSION >= (4, 0): model_state = self.autodetector.to_state.models[ self.app_label, operation.name.lower() ] view_options = model_state.view_options else: model = self.autodetector.new_apps.get_model( self.app_label, operation.name ) view_options = model._view_meta.original_attrs _, args, kwargs = operation.deconstruct() view_kwargs = {**kwargs, "view_options": view_options} self.add(operations.PostgresCreateViewModel(*args, **view_kwargs)) def add_delete_view_model(self, operation: DeleteModel): """Adds a :see:PostgresDeleteViewModel operation to the list of operations to execute in the migration.""" _, args, kwargs = operation.deconstruct() return self.add(operations.PostgresDeleteViewModel(*args, **kwargs)) def add_create_materialized_view_model(self, operation: CreateModel): """Adds a :see:PostgresCreateMaterializedViewModel operation to the list of operations to execute in the migration.""" if django.VERSION >= (4, 0): model_state = self.autodetector.to_state.models[ self.app_label, operation.name.lower() ] view_options = model_state.view_options else: model = self.autodetector.new_apps.get_model( self.app_label, operation.name ) view_options = model._view_meta.original_attrs _, args, kwargs = operation.deconstruct() view_kwargs = {**kwargs, "view_options": view_options} self.add( operations.PostgresCreateMaterializedViewModel( *args, **view_kwargs, ) ) def add_delete_materialized_view_model(self, operation: DeleteModel): """Adds a :see:PostgresDeleteMaterializedViewModel operation to the list of operations to execute in the migration.""" _, args, kwargs = operation.deconstruct() return self.add( operations.PostgresDeleteMaterializedViewModel(*args, **kwargs) ) @contextmanager def patched_autodetector(): """Patches the standard Django :seee:MigrationAutodetector for the duration of the context. The patch intercepts the `add_operation` function to customize how new operations are added. We have to do this because there is no way in Django to extend the auto detector otherwise. """ autodetector_module_path = "django.db.migrations.autodetector" autodetector_class_path = ( f"{autodetector_module_path}.MigrationAutodetector" ) add_operation_path = f"{autodetector_class_path}.add_operation" def _patched(autodetector, app_label, operation, *args, **kwargs): handler = AddOperationHandler(autodetector, app_label, args, kwargs) if isinstance(operation, CreateModel): return handler.add_create_model(operation) if isinstance(operation, DeleteModel): return handler.add_delete_model(operation) if isinstance(operation, AddField): return handler.add_field(operation) if isinstance(operation, RemoveField): return handler.remove_field(operation) if isinstance(operation, AlterField): return handler.alter_field(operation) if isinstance(operation, RenameField): return handler.rename_field(operation) return handler.add(operation) with mock.patch(add_operation_path, new=_patched): yield