Opened 18 months ago

Last modified 18 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 Alexis Lesieur)

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.

Change History (1)

comment:1 by Alexis Lesieur, 18 months ago

Description: modified (diff)
Note: See TracTickets for help on using tickets.
Back to Top