Django回退迁移时无法删除数据行的异常问题排查
Django迁移反向回退时删除操作触发类型匹配错误
问题描述
给MediaType模型添加preview的TextChoice选项,通过RunPython的converter方法批量创建对应MediaType及关联的MediaSize数据,正向迁移执行正常。但反向回退迁移时,执行reverter删除name为preview的MediaType实例,触发矛盾的类型错误:ValueError: Cannot query "MediaSize object (0a803f4c-f9e0-4b2d-ae2a-c02d7b9e12b6)": Must be "MediaSize" instance.
错误日志
Unapplying applications.0012_alter_mediatype_name...Traceback (most recent call last): File "/usr/local/lib/python3.10/runpy.py", line 196, in _run_module_as_main return _run_code(code, main_globals, None, File "/usr/local/lib/python3.10/runpy.py", line 86, in _run_code exec(code, run_globals) File "/root/.vscode-server/extensions/ms-python.python-2022.20.2/pythonFiles/lib/python/debugpy/adapter/../../debugpy/launcher/../../debugpy/__main__.py", line 39, in <module> cli.main() File "/root/.vscode-server/extensions/ms-python.python-2022.20.2/pythonFiles/lib/python/debugpy/adapter/../../debugpy/launcher/../../debugpy/../debugpy/server/cli.py", line 430, in main run() File "/root/.vscode-server/extensions/ms-python.python-2022.20.2/pythonFiles/lib/python/debugpy/adapter/../../debugpy/launcher/../../debugpy/../debugpy/server/cli.py", line 284, in run_file runpy.run_path(target, run_name="__main__") File "/root/.vscode-server/extensions/ms-python.python-2022.20.2/pythonFiles/lib/python/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_runpy.py", line 321, in run_path return _run_module_code(code, init_globals, run_name, File "/root/.vscode-server/extensions/ms-python.python-2022.20.2/pythonFiles/lib/python/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_runpy.py", line 135, in _run_module_code _run_code(code, mod_globals, init_globals, File "/root/.vscode-server/extensions/ms-python.python-2022.20.2/pythonFiles/lib/python/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_runpy.py", line 124, in _run_code exec(code, run_globals) File "/code/ews/manage.py", line 22, in <module> main() File "/code/ews/manage.py", line 18, in main execute_from_command_line(sys.argv) File "/usr/local/lib/python3.10/site-packages/django/core/management/__init__.py", line 446, in execute_from_command_line utility.execute() File "/usr/local/lib/python3.10/site-packages/django/core/management/__init__.py", line 440, in execute self.fetch_command(subcommand).run_from_argv(self.argv) File "/usr/local/lib/python3.10/site-packages/django/core/management/base.py", line 414, in run_from_argv self.execute(*args, **cmd_options) File "/usr/local/lib/python3.10/site-packages/django/core/management/base.py", line 460, in execute output = self.handle(*args, **options) File "/usr/local/lib/python3.10/site-packages/django/core/management/base.py", line 98, in wrapped res = handle_func(*args, **kwargs) File "/usr/local/lib/python3.10/site-packages/django/core/management/commands/migrate.py", line 290, in handle post_migrate_state = executor.migrate( File "/usr/local/lib/python3.10/site-packages/django/db/migrations/executor.py", line 137, in migrate state = self._migrate_all_backwards(plan, full_plan, fake=fake) File "/usr/local/lib/python3.10/site-packages/django/db/migrations/executor.py", line 215, in _migrate_all_backwards self.unapply_migration(states[migration], migration, fake=fake) File "/usr/local/lib/python3.10/site-packages/django/db/migrations/executor.py", line 275, in unapply_migration state = migration.unapply(state, schema_editor) File "/usr/local/lib/python3.10/site-packages/django/db/migrations/migration.py", line 191, in unapply operation.database_backwards( File "/usr/local/lib/python3.10/site-packages/django/db/migrations/operations/special.py", line 201, in database_backwards self.reverse_code(from_state.apps, schema_editor) File "/code/ews/applications/migrations/0012_alter_mediatype_name.py", line 45, in reverter ).delete() File "/usr/local/lib/python3.10/site-packages/django/db/models/query.py", line 826, in delete collector.collect(del_query) File "/usr/local/lib/python3.10/site-packages/django/db/models/deletion.py", line 343, in collect field.remote_field.on_delete(self, field, sub_objs, self.using) File "/usr/local/lib/python3.10/site-packages/django/db/models/deletion.py", line 23, in CASCADE collector.collect( File "/usr/local/lib/python3.10/site-packages/django/db/models/deletion.py", line 321, in collect sub_objs = self.related_objects(related_model, [field], batch) File "/usr/local/lib/python3.10/site-packages/django/db/models/deletion.py", line 404, in related_objects return related_model._base_manager.using(self.using).filter(predicate) File "/usr/local/lib/python3.10/site-packages/django/db/models/query.py", line 1071, in filter return self._filter_or_exclude(False, args, kwargs) File "/usr/local/lib/python3.10/site-packages/django/db/models/query.py", line 1089, in _filter_or_exclude clone._filter_or_exclude_inplace(negate, args, kwargs) File "/usr/local/lib/python3.10/site-packages/django/db/models/query.py", line 1096, in _filter_or_exclude_inplace self._query.add_q(Q(*args, **kwargs)) File "/usr/local/lib/python3.10/site-packages/django/db/models/sql/query.py", line 1502, in add_q clause, _ = self._add_q(q_object, self.used_aliases) File "/usr/local/lib/python3.10/site-packages/django/db/models/sql/query.py", line 1532, in _add_q child_clause, needed_inner = self.build_filter( File "/usr/local/lib/python3.10/site-packages/django/db/models/sql/query.py", line 1358, in build_filter return self._add_q( File "/usr/local/lib/python3.10/site-packages/django/db/models/sql/query.py", line 1532, in _add_q child_clause, needed_inner = self.build_filter( File "/usr/local/lib/python3.10/site-packages/django/db/models/sql/query.py", line 1415, in build_filter self.check_related_objects(join_info.final_field, value, join_info.opts) File "/usr/local/lib/python3.10/site-packages/django/db/models/sql/query.py", line 1229, in check_related_objects self.check_query_object_type(v, opts, field) File "/usr/local/lib/python3.10/site-packages/django/db/models/sql/query.py", line 1203, in check_query_object_type raise ValueError( ValueError: Cannot query "MediaSize object (0a803f4c-f9e0-4b2d-ae2a-c02d7b9e12b6)": Must be "MediaSize" instance.
排查尝试
- 在迁移外执行相同删除代码,可正常运行;
- 迁移中途停止后单独删除
MediaSize实例,仍触发相同错误; - 有无调试器均报错;
- 迁移中可更新保存实例,但删除操作必报错;
- 将
AlterField和RunPython操作拆分为两个独立迁移文件并分别执行时,反向回退正常;合并在一个迁移中则报错。
迁移代码
from django.db import migrations, models def converter(apps, schema_editor): AppExtension = apps.get_model("applications", "AppExtension") MediaType = apps.get_model("applications", "MediaType") MediaSize = apps.get_model("applications", "MediaSize") all_extensions = AppExtension.objects.all() media_types = [] media_sizes = [] for extension in all_extensions: mediatype_extension = MediaType(extension=extension, name="preview") media_types.append(mediatype_extension) media_sizes.append( MediaSize( media_type=mediatype_extension, name="intrinsic", width=1920, height=960, ) ) media_sizes.append( MediaSize( media_type=mediatype_extension, name="optimized", width=960, height=480, ) ) MediaType.objects.bulk_create(media_types) MediaSize.objects.bulk_create(media_sizes) def reverter(apps, schema_editor): MediaType = apps.get_model("applications", "MediaType") all_extensions = AppExtension.objects.all() MediaType.objects.filter( name="preview", ).delete() class Migration(migrations.Migration): dependencies = [ ("applications", "0011_alter_mediatype_name"), ] operations = [ migrations.AlterField( model_name="mediatype", name="name", field=models.CharField( choices=[ ("thumbnail", "Thumbnail"), ("icon", "Icon"), ("app-icon", "App Icon"), ("preview", "Preview"), ], default="thumbnail", help_text="Name of this media type", max_length=32, verbose_name="Name", ), ), migrations.RunPython(converter, reverse_code=reverter), ]
模型代码
class MediaType(UUIDModel): class TypeChoices(models.TextChoices): THUMBNAIL = "thumbnail", "Thumbnail" ICON = "icon", "Icon" APP_ICON = "app-icon", "App Icon" PREVIEW = "preview", "Preview" extension = models.ForeignKey( AppExtension, on_delete=models.CASCADE, related_name="media_types", verbose_name=_("AppExtension"), help_text="AppExtension this MediaType belongs to", ) name = models.CharField( max_length=32, verbose_name=_('Name'), help_text=f"Name of this media type", choices=TypeChoices.choices, default=TypeChoices.THUMBNAIL, ) @property def sizes_dict(self) -> dict: if self.sizes.exist(): return {size.name: size.get_tuple() for size in self.sizes.all()} return {} def __str__(self) -> str: return f"{self.extension.name} - {self.name}" class Meta: constraints = [ UniqueConstraint( fields=["extension", "name"], name="unique_media_type_name_per_extension", ), ] class MediaSize(UUIDModel): class SizeChoices(models.TextChoices): INTRINSIC = "intrinsic", "Intrinsic" OPTIMIZED = "optimized", "Optimized" media_type = models.ForeignKey( MediaType, on_delete=models.CASCADE, verbose_name=_('Media Type'), help_text="Media type this size reffers to", related_name="sizes", ) name = models.CharField( max_length=32, verbose_name=_('Name'), help_text=f"Name of this media size", choices=SizeChoices.choices, default=SizeChoices.INTRINSIC, ) width = models.PositiveIntegerField( verbose_name=_('Width'), help_text="Width of this media size", ) height = models.PositiveIntegerField( verbose_name=_('Height'), help_text="Height of this media size", ) def get_tuple(self) -> Tuple[int, int]: return self.width, self.height def __str__(self) -> str: return ( f"{self.media_type.extension.name} - {self.media_type.name}" + f" - {self.name}: ({self.get_tuple()})" ) class Meta: constraints = [ UniqueConstraint( fields=["media_type", "name"], name="unique_media_size_name_per_media_type", ), UniqueConstraint( fields=["media_type", "height", "width"], name="unique_media_size_dimensions_per_media_type", ), ]
原因分析
这是Django迁移反向执行时的状态模型与数据库数据不匹配导致的:
反向回退单个迁移时,操作执行顺序为先运行RunPython的reverse_code,再执行AlterField的反向操作。删除MediaType时,CASCADE会触发查询关联的MediaSize,但此时from_state.apps中的MediaSize模型是迁移前的版本(未关联新增的preview类型),而数据库中的MediaSize实例是由正向迁移创建的,二者属于不同的模型类对象,导致类型校验失败。
解决方案
方案1:拆分迁移文件
将AlterField和RunPython操作拆分为两个独立的迁移:
- 第一个迁移仅执行
AlterField,添加preview选项; - 第二个迁移执行
RunPython,创建对应数据。
反向回退时,会先回退第二个迁移(删除数据),再回退第一个迁移(移除preview选项),避免模型状态与数据的冲突。
方案2:修改reverter主动删除关联数据
在reverter中显式删除关联的MediaSize,再删除MediaType,避免CASCADE触发的类型不匹配查询:
def reverter(apps, schema_editor): MediaType = apps.get_model("applications", "MediaType") MediaSize = apps.get_model("applications", "MediaSize") # 先删除关联的MediaSize实例 MediaSize.objects.filter(media_type__name="preview").delete() # 再删除目标MediaType实例 MediaType.objects.filter(name="preview").delete()
内容的提问来源于stack exchange,提问作者Denise Albertazzi
相关产品推荐
相关产品推荐

