#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.

Reply via email to