Opened 3 weeks ago

Closed 2 weeks ago

#36878 closed Bug (fixed)

Migration's ModelState has varying type for unique_together and index_together options causing autodetector crash

Reported by: Markus Holtermann Owned by: Markus Holtermann
Component: Migrations Version: dev
Severity: Normal Keywords:
Cc: Lily Triage Stage: Ready for checkin
Has patch: yes Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

When leveraging the MigrationAutodetector to get the changes between two project states, the following exception is raised (on 5.2, but the same applies to the current master at commit b1ffa9a9d78b0c2c5ad6ed5a1d84e380d5cfd010):

Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File "src/manage.py", line 67, in <module>
    management.execute_from_command_line(sys.argv)
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
  File ".venv/lib/python3.13/site-packages/django/core/management/__init__.py", line 442, in execute_from_command_line
    utility.execute()
    ~~~~~~~~~~~~~~~^^
  File ".venv/lib/python3.13/site-packages/django/core/management/__init__.py", line 436, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
  File ".venv/lib/python3.13/site-packages/django/core/management/base.py", line 420, in run_from_argv
    self.execute(*args, **cmd_options)
    ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.13/site-packages/django/core/management/base.py", line 464, in execute
    output = self.handle(*args, **options)
  File "src/core/management/commands/some_command.py", line 94, in handle
    self._get_migrations_and_operations(section)
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
  File "src/core/management/commands/some_command.py", line 238, in _get_migrations_and_operations
    new_migrations = autodetector.changes(self.graph, trim_to_apps={"affiliates"})
  File ".venv/lib/python3.13/site-packages/django/db/migrations/autodetector.py", line 67, in changes
    changes = self._detect_changes(convert_apps, graph)
  File ".venv/lib/python3.13/site-packages/django/db/migrations/autodetector.py", line 213, in _detect_changes
    self.generate_removed_altered_unique_together()
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
  File ".venv/lib/python3.13/site-packages/django/db/migrations/autodetector.py", line 1718, in generate_removed_altered_unique_together
    self._generate_removed_altered_foo_together(operations.AlterUniqueTogether)
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.13/site-packages/django/db/migrations/autodetector.py", line 1699, in _generate_removed_altered_foo_together
    ) in self._get_altered_foo_together_operations(operation.option_name):
         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.13/site-packages/django/db/migrations/autodetector.py", line 1668, in _get_altered_foo_together_operations
    new_value = set(new_value) if new_value else set()
                ~~~^^^^^^^^^^^
TypeError: unhashable type: 'list'

Reason:

The migration ModelState tracks the changes for all options (order_with_respect_to, unique_together, indexes, ...) in its options attribute, which is a simple dict. For most keys inside the dict, the value is just a list of something. However, for unique_together and index_together, the value should be a set of tuples (see the ModelState.from_model() method).

Unfortunately, there are situations inside the ProjectState's mutation functions (e.g. rename_field()) where the data type for model_state.options["unique_together"] is changed to list[list[str]]:

        for option in ("index_together", "unique_together"):
            if option in options:
                options[option] = [
                    [new_name if n == old_name else n for n in together]
                    for together in options[option]
                ]

Change History (8)

comment:1 by Simon Charette, 3 weeks ago

Triage Stage: UnreviewedAccepted
Type: UncategorizedBug

comment:2 by Markus Holtermann, 3 weeks ago

In order to understand where the incorrect setting or the values came from, I applied the following changes to the state.py:

  • django/db/migrations/state.py

    diff --git a/django/db/migrations/state.py b/django/db/migrations/state.py
    index 057651b1df..7220673142 100644
    a b class StateApps(Apps):  
    734734            pass
    735735
    736736
     737class OptionsDict(dict):
     738    def __getitem__(self, key):
     739        val = super().__getitem__(key)
     740        if "together" in key:
     741            print(f"__getitem__({key!r}) = {val!r}")
     742        return val
     743
     744    def __setitem__(self, key, value):
     745        if "together" in key:
     746            import inspect, textwrap
     747
     748            print(f"__setitem__({key!r}, {value!r})")
     749            if not isinstance(value, set | tuple):
     750                calling_frame = inspect.stack(3)[1]
     751                code = textwrap.indent("".join(calling_frame.code_context), "    ")
     752                print(
     753                    f"  {calling_frame.filename}:{calling_frame.lineno} in {calling_frame.function}\n{code}"
     754                )
     755        return super().__setitem__(key, value)
     756
     757    def __delitem__(self, key):
     758        if "together" in key:
     759            print(f"__delitem__({key!r})")
     760        return super().__delitem__(key)
     761
     762
    737763class ModelState:
    738764    """
    739765    Represent a Django Model. Don't use the actual Model class as it's not
    class ModelState:  
    751777        self.app_label = app_label
    752778        self.name = name
    753779        self.fields = dict(fields)
    754         self.options = options or {}
     780        self.options = OptionsDict(options or {})
    755781        self.options.setdefault("indexes", [])
    756782        self.options.setdefault("constraints", [])
    757783        self.bases = bases or (models.Model,)
    class ModelState:  
    783809                    "%r doesn't have one." % index
    784810                )
    785811
     812    def __getattr__(self, name):
     813        val = super().__getattr__(name)
     814        if (
     815            name == "options"
     816            and val
     817            and ("unique_together" in value or "index_together" in value)
     818        ):
     819            print(f"__getattr__({name!r}) = {val!r}")
     820        return val
     821
     822    def __setattr__(self, name, value):
     823        if name == "options" and (
     824            "unique_together" in value or "index_together" in value
     825        ):
     826            import inspect, textwrap
     827
     828            if (
     829                "unique_together" in value
     830                and not isinstance(value["unique_together"], set | tuple)
     831                or "index_together" in value
     832                and not isinstance(value["index_together"], set | tuple)
     833            ):
     834                print(f"__setattr__({name!r}, {value!r})")
     835                for i in range(1, 4):
     836                    calling_frame = inspect.stack(3)[i]
     837                    code = textwrap.indent("".join(calling_frame.code_context), "    ")
     838                    print(
     839                        f"  {calling_frame.filename}:{calling_frame.lineno} in {calling_frame.function}\n{code}"
     840                    )
     841        return super().__setattr__(name, value)
     842
    786843    @cached_property
    787844    def name_lower(self):
    788845        return self.name.lower()

comment:3 by Markus Holtermann, 3 weeks ago

Has patch: set

comment:4 by Lily, 3 weeks ago

Triage Stage: AcceptedReady for checkin

comment:5 by Jacob Walls, 2 weeks ago

Needs tests: set
Triage Stage: Ready for checkinAccepted

Left a suggestion for covering uncovered code changes.

comment:6 by Markus Holtermann, 2 weeks ago

Has patch: unset

comment:7 by Jacob Walls, 2 weeks ago

Has patch: set
Needs tests: unset
Triage Stage: AcceptedReady for checkin

Ready to go pending a flake8 fix.

comment:8 by Jacob Walls <jacobtylerwalls@…>, 2 weeks ago

Resolution: fixed
Status: assignedclosed

In 83622b82:

Fixed #36878 -- Unified data type for *_together options in ModelState.

Ever since the beginning of Django's migration framework, there's been a
bit of an inconsistency on how index_together and unique_together values
have been stored on the ModelState[1].

It's only really obvious, when looking at the current code for
from_model()[2] and the rename_field() state alteration code[3].

The problem in the autodetector's detection of the *_together options
as raised in the ticket, reinforces the inconsistency[4]: the old value
is being normalized to a set of tuples, whereas the new value is taken
as-is.

Why this hasn't been caught before, is likely to the fact, that we
never really look at a to_state that comes from migration operations
in the autodetector. Instead, in both usages in Django[5], [6] the
to_state is a ProjectState.from_apps(). And that state is
consistently using sets of tuples and not lists of lists.

[1]: https://github.com/django/django/commit/67dcea711e92025d0e8676b869b7ef15dbc6db73#diff-5dd147e9e978e645313dd99eab3a7bab1f1cb0a53e256843adb68aeed71e61dcR85-R87
[
2]: https://github.com/django/django/blob/b1ffa9a9d78b0c2c5ad6ed5a1d84e380d5cfd010/django/db/migrations/state.py#L842
[3]: https://github.com/django/django/blob/b1ffa9a9d78b0c2c5ad6ed5a1d84e380d5cfd010/django/db/migrations/state.py#L340-L345
[
4]: https://github.com/django/django/blob/b1ffa9a9d78b0c2c5ad6ed5a1d84e380d5cfd010/django/db/migrations/autodetector.py#L1757-L1771
[5]: https://github.com/django/django/blob/2351c1b12cc9cf82d642f769c774bc3ea0cc4006/django/core/management/commands/makemigrations.py#L215-L219
[
6]: https://github.com/django/django/blob/2351c1b12cc9cf82d642f769c774bc3ea0cc4006/django/core/management/commands/migrate.py#L329-L332

Note: See TracTickets for help on using tickets.
Back to Top