Skip to content

Commit

Permalink
Warn on attempts at model and field renames.
Browse files Browse the repository at this point in the history
Suggest safe alternatives to renames while allowing possibly unsafe defaults
to be used with an explicit stage.
  • Loading branch information
charettes committed May 7, 2021
1 parent 28f77a7 commit 5344a11
Show file tree
Hide file tree
Showing 4 changed files with 331 additions and 6 deletions.
102 changes: 101 additions & 1 deletion syzygy/autodetector.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@
from django.utils.functional import cached_property

from .compat import get_model_state_field
from .constants import Stage
from .exceptions import AmbiguousStage
from .operations import AddField, PostAddField, PreRemoveField
from .operations import (
AddField,
PostAddField,
PreRemoveField,
RenameField,
RenameModel,
)
from .plan import partition_operations


Expand Down Expand Up @@ -49,12 +56,105 @@ class MigrationAutodetector(_MigrationAutodetector):

STAGE_SPLIT = "__stage__"

def __init__(self, *args, **kwargs):
self.style = kwargs.pop("style", None)
super().__init__(*args, **kwargs)

@cached_property
def has_interactive_questionner(self) -> bool:
return not self.questioner.dry_run and isinstance(
self.questioner, InteractiveMigrationQuestioner
)

def add_operation(self, app_label, operation, dependencies=None, beginning=False):
if isinstance(operation, operations.RenameField):
print(
self.style.WARNING(
"Renaming a column from a database table actively relied upon might cause downtime "
"during deployment.",
),
file=sys.stderr,
)
answer = self.questioner.defaults.get("ask_rename_field_stage", 0)
if self.has_interactive_questionner:
answer = self.questioner._choice_input(
"Please choose an appropriate action to take:",
[
(
f"Quit, and let me add a new {operation.model_name}.{operation.new_name} field meant "
f"to be backfilled with {operation.model_name}.{operation.old_name} values"
),
(
f"Assume the currently deployed code doesn't reference {app_label}.{operation.model_name} "
f"on reachable code paths and mark the operation to be applied before deployment. "
+ self.style.MIGRATE_LABEL(
"This might cause downtime if your assumption is wrong",
)
),
(
f"Assume the newly deployed code doesn't reference {app_label}.{operation.model_name} on "
"reachable code paths and mark the operation to be applied after deployment. "
+ self.style.MIGRATE_LABEL(
"This might cause downtime if your assumption is wrong",
)
),
],
)
if answer == 0:
sys.exit(3)
else:
stage = Stage.PRE_DEPLOY if answer == 1 else Stage.POST_DEPLOY
operation = RenameField.for_stage(operation, stage)
if isinstance(operation, operations.RenameModel):
from_db_table = (
self.from_state.models[app_label, operation.old_name_lower].options.get(
"db_table"
)
or f"{app_label}_{operation.old_name_lower}"
)
to_db_table = self.to_state.models[
app_label, operation.new_name_lower
].options.get("db_table")
if from_db_table != to_db_table:
print(
self.style.WARNING(
"Renaming an actively relied on database table might cause downtime during deployment."
),
file=sys.stderr,
)
answer = self.questioner.defaults.get("ask_rename_model_stage", 0)
if self.has_interactive_questionner:
answer = self.questioner._choice_input(
"Please choose an appropriate action to take:",
[
(
f"Quit, and let me manually set {app_label}.{operation.new_name}.Meta.db_table to "
f'"{from_db_table}" to avoid renaming its underlying table'
),
(
f"Assume the currently deployed code doesn't reference "
f"{app_label}.{operation.old_name} on reachable code paths and mark the operation to "
"be applied before the deployment. "
+ self.style.MIGRATE_LABEL(
"This might cause downtime if your assumption is wrong",
)
),
(
f"Assume the newly deployed code doesn't reference {app_label}.{operation.new_name} "
"on reachable code paths and mark the operation to be applied after the deployment. "
+ self.style.MIGRATE_LABEL(
"This might cause downtime if your assumption is wrong",
)
),
],
)
if answer == 0:
sys.exit(3)
else:
stage = Stage.PRE_DEPLOY if answer == 1 else Stage.POST_DEPLOY
operation = RenameModel.for_stage(operation, stage)
super().add_operation(app_label, operation, dependencies, beginning)

def _generate_added_field(self, app_label, model_name, field_name):
super()._generate_added_field(app_label, model_name, field_name)
add_field = self.generated_operations[app_label][-1]
Expand Down
6 changes: 5 additions & 1 deletion syzygy/management/commands/makemigrations.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from functools import partial

from django.core.management.commands import makemigrations # type: ignore

from syzygy.autodetector import MigrationAutodetector
Expand All @@ -21,7 +23,9 @@ def handle(self, *args, disable_syzygy, **options):
# Monkey-patch makemigrations.MigrationAutodetector since the command
# doesn't allow it to be overridden in any other way.
MigrationAutodetector_ = makemigrations.MigrationAutodetector
makemigrations.MigrationAutodetector = MigrationAutodetector
makemigrations.MigrationAutodetector = partial(
MigrationAutodetector, style=self.style
)
try:
super().handle(*args, **options)
finally:
Expand Down
33 changes: 33 additions & 0 deletions syzygy/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,36 @@ def describe(self):
self.name,
self.model_name,
)


class StagedOperation(operations.base.Operation):
stage: Stage

def __init__(self, *args, **kwargs):
self.stage = kwargs.pop("stage")
super().__init__(*args, **kwargs)

@classmethod
def for_stage(cls, operation: operations.base.Operation, stage: Stage):
_, args, kwargs = operation.deconstruct()
kwargs["stage"] = stage
return cls(*args, **kwargs)

def deconstruct(self):
name, args, kwargs = super().deconstruct()
kwargs["stage"] = self.stage
return name, args, kwargs


class RenameField(StagedOperation, operations.RenameField):
"""
Subclass of ``RenameField`` that explicitly defines a stage for the rare
instances where a rename operation is safe to perform.
"""


class RenameModel(StagedOperation, operations.RenameModel):
"""
Subclass of ``RenameModel`` that explicitly defines a stage for the rare
instances where a rename operation is safe to perform.
"""

0 comments on commit 5344a11

Please sign in to comment.