Django model objects became not hashable after upgrading to django 2.2
Asked Answered
H

1

7

I'm testing the update of an application from Django 2.1.7 to 2.2.12. I got an error when running my unit tests, which boils down to a model object not being hashable :

    Station.objects.all().delete()
py37\lib\site-packages\django\db\models\query.py:710: in delete
    collector.collect(del_query)
py37\lib\site-packages\django\db\models\deletion.py:192: in collect
    reverse_dependency=reverse_dependency)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <django.db.models.deletion.Collector object at 0x000001EC78243E80>
objs = <QuerySet [<Station(nom='DUNKERQUE')>, <Station(nom='STATION1')>, <Station(nom='STATION2')>]>, source = None, nullable = False
reverse_dependency = False

    def add(self, objs, source=None, nullable=False, reverse_dependency=False):
        """
        Add 'objs' to the collection of objects to be deleted.  If the call is
        the result of a cascade, 'source' should be the model that caused it,
        and 'nullable' should be set to True if the relation can be null.

        Return a list of all objects that were not already collected.
        """
        if not objs:
            return []
        new_objs = []
        model = objs[0].__class__
        instances = self.data.setdefault(model, set())
        for obj in objs:
>           if obj not in instances:
E           TypeError: unhashable type: 'Station'

Instances of model objects are hashable in Django, once they are saved to database and get a primary key.

I don't understand where the error comes from and why I get this when running this basic code:

In [7]: s = Station.objects.create(nom='SOME PLACE')

In [8]: hash(s)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-8-9333020f3184> in <module>
----> 1 hash(s)

TypeError: unhashable type: 'Station'

In [9]: s.pk
Out[9]: 2035

All this code works fine when I switch back to Django 2.1.7. The same happens with other model objects in the app. I'm using python version 3.7.2 on Windows, with a SQlite backend (on the development workstation).

Edit: Here's the definition of the model referred to above:

class Station(models.Model):
    nom = models.CharField(max_length=200, unique=True)

    def __str__(self):
        return self.nom

    def __repr__(self):
        return "<Station(nom='{}')>".format(self.nom)

    def __eq__(self, other):
        return isinstance(other, Station) and self.nom == other.nom
Handknit answered 14/4, 2020 at 16:23 Comment(6)
Have you seen this ticket? Are you overriding __eq__ in your Station model?Gianna
Hi @Alasdair, I hadn't but that's exactly it, my models override __eq__. Overriding __hash__ fixes the problem. Do you want to write an answer so I can give you credit ?Handknit
Glad that helped. Happy for you to add your own answer you probably know the details better than me.Gianna
Actually, if this helped in identifying the reason (since django 2.2, an instance of a model overriding __eq__ but not __hash__ has a hash set to None), the fix suggested in the ticket doesn't seem appropriate to me : every instance would return the same hash (unless I'm missing something), which is of course not the correct behaviour.Handknit
If you set __hash__ = models.Model.__hash__ in Django 2.2, then Student(pk=1) and Student(pk=2) will have different hashes. Student(pk=1) and OtherModel(pk=1) will have the same hash, because the Model.__hash__ method returns hash(self.pk). That's the same behaviour in Django 2.1. If you want different models with the same pk to return different hashes then you need to override __hash__ in Django 2.1 and 2.2.Gianna
I was trying to be smart with my fix, by overriding __hash__ rather than reassigning it, but got it wrong. All clear, thanks.Handknit
H
19

As pointed out by @Alasdair, the issue was a change of behaviour brought in Django 2.2 to comply with how a model class should behave when __eq__() is overriden but not __hash__(). As per the python docs for __hash__():

A class that overrides __eq__() and does not define __hash__() will have its __hash__() implicitly set to None.

More information about the implementation of this behaviour in Django can be found in this ticket.

The fix can be either the one suggested in the ticket, i.e. re-assigning the __hash__() method of the model to the one of the super class: __hash__ = models.Model.__hash__

Or a more object-oriented way could be:

    def __hash__(self):
        return super().__hash__()

This seems a bit weird because this should be unnecessary: by default, a call to __hash__() should use the method from the super class where it's implemented. This suggests Django breaks encapsulation somehow. But maybe I don't understand everything. Anyway that's a sidenote.

In my case, I still wanted to be able to compare model instances not yet saved to the database for testing purposes and ended up with this implementation :

    def __hash__(self):
        if self.pk is None:
            return hash(self.nom)
        return super().__hash__()
Handknit answered 15/4, 2020 at 17:51 Comment(1)
I wouldn't describe it as Django breaking encapsulation. In 2.2+ it's following the documented Python behaviour. This answer links to ticket 1549, where Guido explains it's so that your objects don't misbehave when used as dictionary keys.Gianna

© 2022 - 2024 — McMap. All rights reserved.