Opened 21 months ago
Last modified 21 months ago
#34433 closed New feature
OneToOneField can only be saved one way — at Version 1
Reported by: | Alexis Lesieur | Owned by: | nobody |
---|---|---|---|
Component: | Database layer (models, ORM) | Version: | 4.1 |
Severity: | Normal | Keywords: | |
Cc: | Triage Stage: | Unreviewed | |
Has patch: | no | Needs documentation: | no |
Needs tests: | no | Patch needs improvement: | no |
Easy pickings: | no | UI/UX: | no |
Description (last modified by )
Hi!
I encountered this unexpected (to me) behavior for work, and I have been able to replicate on a bare django app (albeit with slightly different symptoms).
The TLDR is that is model A has a OneToOneField to model B. The field had to be saved from the instance of model A, and that's not only not documented anywhere I could find, but counter-intuitive, and contradicts how other fields like ForeignKeys work.
Setup:
❯ python --version Python 3.11.2 ❯ pip freeze | grep -i django Django==4.1.7 ❯ django-admin startproject mysite ❯ cd mysyte/ ❯ django-admin startapp myapp ❯ vim myapp/models.py # partially re-using your example from https://docs.djangoproject.com/en/4.1/topics/db/examples/one_to_one/ ``` from django.db import models class Place(models.Model): name = models.CharField(max_length=50) address = models.CharField(max_length=80) def __str__(self): return "%s the place" % self.name class Restaurant(models.Model): place = models.OneToOneField( Place, on_delete=models.CASCADE, ) serves_hot_dogs = models.BooleanField(default=False) serves_pizza = models.BooleanField(default=False) def __str__(self): return "%s the restaurant" % self.place.name ``` ❯ vim mysite/settings.py [...] INSTALLED_APPS = [ 'myapp.apps.MyappConfig', [...] ❯ python manage.py makemigrations ❯ python manage.py migrate
Creating the initial objects:
❯ python manage.py shell ❯ from myapp.models import Place ❯ from myapp.models import Restaurant ❯ p1 = Place(name="1st place", address="1st address") ❯ p2 = Place(name="2nd place", address="2nd address") ❯ r1 = Restaurant(place=p1) ❯ r2 = Restaurant(place=p2) ❯ p1.save() ❯ p2.save() ❯ r1.save() ❯ r2.save() ❯ p3 = Place(name="3rd place", address="3rd address") ❯ p3.save()
This should give us a two restaurants with their respective places, and an additional place we can use to play.
First, what works:
❯ r1.place = p3 ❯ r1.save() ❯ Restaurant.objects.get(id=1).place <Place: 3rd place the place> ❯ p3.restaurant <Restaurant: 3rd place the restaurant> ❯ Place.objects.get(id=1).restaurant [...] RelatedObjectDoesNotExist: Place has no restaurant.
This is all expected. r1
now uses p3
, which means that p1
has no restaurant assigned.
Now I would expect, to be able to do the other way. Assign a new restaurant to a place, save, and be all good.
However that doesn't work.
First using plain .save()
which fails silently:
❯ p1 = Place.objects.get(id=1) ❯ p1.restaurant = r1 ❯ p1.save() ❯ Restaurant.objects.get(id=1).place <Place: 3rd place the place> # this should be p1
And when explicitly asking to save the field:
❯ p1.save(update_fields=["restaurant"]) ❯ ValueError: The following fields do not exist in this model, are m2m fields, or are non-concrete fields: restaurant
NB: on my use case for work (django 3.2.18) I was also getting the following error:
UniqueViolation: duplicate key value violates unique constraint "response_timelineevent_pkey" DETAIL: Key (id)=(91) already exists.
I'm not sure why it's different, but it doesn't work either way.
This is problematic for a few reasons IMO:
- Unless I missed it, the docs really don't advertise this limitation.
.save()
"fails" silently, there is no way to know that the change didn't take, especially when this happens:❯ p1 = Place(name="1st place", address="1st address") ❯ p2 = Place(name="2nd place", address="2nd address") ❯ p3 = Place(name="3rd place", address="3rd address") ❯ p1.save() ❯ p2.save() ❯ p3.save() ❯ r1 = Restaurant(place=p1) ❯ r1.save() ❯ r2 = Restaurant(place=p2) ❯ r2.save() ❯ r1.place <Place: 1st place the place> ❯ p3.restaurant = r1 ❯ r1.place <Place: 3rd place the place> ❯ p3.save() ❯ Restaurant.objects.get(id=1).place <Place: 1st place the place>
which leads to thinking the change is working and affecting both objects, when it's not.
It's also problematic as foreigh keys work this way: (from my work example)
❯ me = ExternalUser.objects.get(id=1) ❯ other = ExternalUser.objects.get(id=2) ❯ p = PinnedMessage.objects.get(id=11) ❯ p.author <ExternalUser: first.last (slack)> # i.e. `me` ❯ [p.id for p in me.authored_pinnedmessage.all()] [1, 3, 5, 11] ❯ p.author = other ❯ p.save() ❯ [p.id for p in ExternalUser.objects.get(id=1).authored_pinnedmessage.all()] [1, 3, 5] ❯ me.authored_pinnedmessage.add(p) ❯ me.save() ❯ PinnedMessage.objects.get(id=11).author <ExternalUser: first.last (slack)>
Hopefully this is all enough explanation / details.
Let me know if you need anything else from me!
Thank you for your help.
[EDIT] This is also counterintuitive because the documentation for OneToOneField
explicitely states:
A one-to-one relationship. Conceptually, this is similar to a ForeignKey with unique=True, but the “reverse” side of the relation will directly return a single object.