#34433: OneToOneField can only be saved one way
-------------------------------------+-------------------------------------
Reporter: Alexis Lesieur | Owner: nobody
Type: Bug | Status: new
Component: Database layer | Version: 4.1
(models, ORM) |
Severity: Normal | Resolution:
Keywords: | Triage Stage:
| Unreviewed
Has patch: 0 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 0
-------------------------------------+-------------------------------------
Description changed by Alexis Lesieur:
Old description:
> 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.
New description:
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.
--
--
Ticket URL: <https://code.djangoproject.com/ticket/34433#comment:1>
Django <https://code.djangoproject.com/>
The Web framework for perfectionists with deadlines.
--
You received this message because you are subscribed to the Google Groups
"Django updates" group.
To unsubscribe from this group and stop receiving emails from it, send an email
to [email protected].
To view this discussion on the web visit
https://groups.google.com/d/msgid/django-updates/010701870f5d25e9-0293ecb2-ac17-46c7-aa9d-98c6c511c350-000000%40eu-central-1.amazonses.com.