#14043 closed Bug (fixed)
Incorrect and/or confusing behaviour with nullable OneToOneField
| Reported by: | theevilgeek | Owned by: | |
|---|---|---|---|
| Component: | Database layer (models, ORM) | Version: | dev |
| Severity: | Normal | Keywords: | OneToOneField, cascading delete, nullable |
| Cc: | George Sakkis | Triage Stage: | Accepted |
| Has patch: | yes | Needs documentation: | no |
| Needs tests: | yes | Patch needs improvement: | yes |
| Easy pickings: | no | UI/UX: | no |
Description
Attempting to "null" out a nullable OneToOneField before deleting the related object fails to prevent a cascading delete (i.e., both objects are still deleted whereas it seems only the related object ought to be deleted).
Example code:
# Note: using Django trunk
###### MODELS ######
class Person(models.Model):
age = models.PositiveIntegerField()
def die(self):
self.soul.become_ghost()
self.delete()
class Soul(models.Model):
person = models.OneToOneField(Person, null=True)
is_alive = models.BooleanField(default=True)
def become_ghost(self):
self.person = None
self.is_alive = False
self.save()
###### TESTCASE (INTERACTIVE) ######
# Type a few commands in "python manage.py shell"
>>> from app.models import Person, Soul
>>>
>>> bob = Person.objects.create(age=34)
>>> bobs_soul = Soul.objects.create(person=bob)
# Let's see what's happening in MySQL (switching programs...)
mysql> select * from app_person;
+----+-----+
| id | age |
+----+-----+
| 2 | 34 |
+----+-----+
1 row in set (0.00 sec)
mysql> select * from app_soul;
+----+-----------+----------+
| id | person_id | is_alive |
+----+-----------+----------+
| 2 | 2 | 1 |
+----+-----------+----------+
1 row in set (0.00 sec)
# Okay, that looks good; let's kill him (switching programs again...)
>>> bob.die()
# Back to MySQL
mysql> select * from app_person;
Empty set (0.00 sec)
mysql> select * from app_soul;
Empty set (0.00 sec)
### Huh!??! Why is app_soul being deleted?
Attachments (1)
Change History (17)
follow-up: 5 comment:1 by , 15 years ago
| Cc: | added |
|---|---|
| Owner: | changed from to |
| Status: | new → assigned |
comment:2 by , 15 years ago
| Owner: | removed |
|---|---|
| Status: | assigned → new |
comment:3 by , 15 years ago
Ugh, selecting "accept ticket" makes me the ticket owner. What's the way to say "I confirm the ticket is legit" without marking me as responsible for it ?
comment:4 by , 15 years ago
| Triage Stage: | Unreviewed → Accepted |
|---|
@gsakkis - You use the triage stage, not the action. Confusing, I know... :-)
comment:5 by , 15 years ago
| Triage Stage: | Accepted → Design decision needed |
|---|
Replying to gsakkis:
Why do an update if it is going to be deleted right after ?
Ugh, scratch that, I missed the self.save() in become_ghost(), that's where the UPDATE comes from; nothing to do with self.delete().
Back to the topic, it comes down to Django's indiscriminate ON DELETE CASCADE behavior, regardless of whether the ForeignKey/OneToOneField is nullable. IMO it would make more sense to treat nullable keys as ON DELETE SET NULL but that would most likely be backwards incompatible at this point. Changing to design decision needed, just in case.
comment:6 by , 15 years ago
| Has patch: | set |
|---|---|
| Triage Stage: | Design decision needed → Ready for checkin |
by , 15 years ago
| Attachment: | ticket-14043.patch added |
|---|
Moved tests from select_related_onetoone to one_to_one_regress
comment:7 by , 15 years ago
| milestone: | → 1.3 |
|---|
comment:8 by , 15 years ago
Looks like this is solved here. I'm adding a little more information for anyone who is trying to work around this in a pre-1.3 version of Django. The following modification fixed a similar problem on my model.
class Person(models.Model):
age = models.PositiveIntegerField()
def die(self):
self.soul.become_ghost()
# Update self here, so that self.soul is unavailalbe in the object passed to delete()
# If self is left alone, then self.soul points to a python object representing a relationship
# that was deleted from the database in the call to become_ghost().
self = Person.objects.get(pk=self.pk)
# OR, the following to work more generally:
# self = self.__class__.objects.get(pk=self.pk)
self.delete()
class Soul(models.Model):
person = models.OneToOneField(Person, null=True)
is_alive = models.BooleanField(default=True)
def become_ghost(self):
self.person = None
self.is_alive = False
self.save()
I'd suggest it as a documentation change for earlier versions, but it's so hack-y.
comment:9 by , 15 years ago
| Needs tests: | set |
|---|---|
| Patch needs improvement: | set |
| Triage Stage: | Ready for checkin → Accepted |
The provided test case doesn't validate the problem described. If you revert the change to related.py, the test case doesn't fail.
comment:10 by , 15 years ago
| Severity: | → Normal |
|---|---|
| Type: | → Bug |
comment:13 by , 12 years ago
I can confirm that this does still happen in master. I tested with this in one_to_one_regress:
def test_nullable_o2o_delete(self):
u = UndergroundBar.objects.create(place=self.p1)
self.p1.delete()
self.assertTrue(UndergroundBar.objects.filter(pk=u.pk).exists())
self.assertIsNone(UndergroundBar.objects.get(pk=u.pk).place)
The attached patch isn't correct at all, it is explicitly breaking the link before delete and then confirming that delete doesn't cascade after that. That isn't the problem, the problem is that OneToOneField that is nullable should be set to None on delete, not cascaded.
comment:14 by , 12 years ago
| Owner: | set to |
|---|---|
| Resolution: | → fixed |
| Status: | new → closed |
Confirmed here too, it's definitely non obvious, if not outright a bug. Also the SQL being issued on
bob.delete()hints that something is fishy:Why do an update if it is going to be deleted right after ?