Opened 4 days ago

Last modified 5 hours ago

#36064 assigned Bug

save() behavior for new model instance with default primary key does not apply to CompositePrimaryKey

Reported by: Jacob Walls Owned by: Csirmaz Bendegúz
Component: Database layer (models, ORM) Version: dev
Severity: Release blocker Keywords:
Cc: Csirmaz Bendegúz Triage Stage: Accepted
Has patch: yes Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

Based on the description of how save() is different than update_or_create() if the model's primary key has a default, i.e. that only an INSERT is attempted if the pk is not set by the user, I would expect models with a CompositePrimaryKey to behave as if they "have a default" if all of their composed fields have a default, or else for this to be called out in the doc.

This test shows this is not the case. The first test showing 2 queries instead of 1 is not such a serious problem, but the second test not raising IntegrityError seems like it could bite a user who was expecting to catch IntegrityError and do a custom update (based on how other models behave since Django 3.0 and #29260).

  • tests/composite_pk/models/tenant.py

    diff --git a/tests/composite_pk/models/tenant.py b/tests/composite_pk/models/tenant.py
    index ac0b3d9715..2273908031 100644
    a b  
     1import uuid
    12from django.db import models
    23
    34
    class Comment(models.Model):  
    4647
    4748class Post(models.Model):
    4849    pk = models.CompositePrimaryKey("tenant_id", "id")
    49     tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    50     id = models.UUIDField()
     50    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, default=1)
     51    id = models.UUIDField(default=uuid.uuid4)
  • tests/composite_pk/test_create.py

    diff --git a/tests/composite_pk/test_create.py b/tests/composite_pk/test_create.py
    index 7c9925b946..af7ceb7df2 100644
    a b  
     1from django.db import IntegrityError
    12from django.test import TestCase
    23
    3 from .models import Tenant, User
     4from .models import Post, Tenant, User
    45
    56
    67class CompositePKCreateTests(TestCase):
    class CompositePKCreateTests(TestCase):  
    1415            id=1,
    1516            email="user0001@example.com",
    1617        )
     18        cls.post = Post.objects.create()
     19
     20    def test_save_default_pk_not_set(self):
     21        with self.assertNumQueries(1):
     22            Post().save()
     23
     24    def test_save_default_pk_set(self):
     25        with self.assertRaises(IntegrityError):
     26            Post(tenant_id=1, id=self.post.id).save()
     27
    1728
    1829    def test_create_user(self):
    1930        test_cases = (

Change History (8)

comment:1 by Sarah Boyce, 3 days ago

Cc: Csirmaz Bendegúz added
Triage Stage: UnreviewedAccepted

Thank you Jacob! Agree this needs either a fix or some docs

comment:2 by mao xin chen, 2 days ago

This is my first time trying to submit code. Please see if it can meet the requirements. If so, I will learn to submit PR. Thanks

  • django/db/models/base.py

    diff --git a/django/db/models/base.py b/django/db/models/base.py
    index a7a26b405c..87c19fd887 100644
    a b class ModelState:  
    455455    # explicit (non-auto) PKs. This impacts validation only; it has no effect
    456456    # on the actual save.
    457457    adding = True
     458    is_set_composite_pk = False
    458459    fields_cache = ModelStateFieldsCacheDescriptor()
    459460
    460461
    class Model(AltersData, metaclass=ModelBase):  
    584585        new = cls(*values)
    585586        new._state.adding = False
    586587        new._state.db = db
     588        if new._meta.is_composite_pk:
     589            new._state.is_set_composite_pk = True
    587590        return new
    588591
    589592    def __repr__(self):
    class Model(AltersData, metaclass=ModelBase):  
    11041107                if f.name in update_fields or f.attname in update_fields
    11051108            ]
    11061109
    1107         if not self._is_pk_set(meta):
    1108             pk_val = meta.pk.get_pk_value_on_save(self)
    1109             setattr(self, meta.pk.attname, pk_val)
    1110         pk_set = self._is_pk_set(meta)
     1110        if meta.is_composite_pk and not (force_update or update_fields):
     1111            pk_set = self._state.is_set_composite_pk
     1112        else:
     1113            pk_set = self._is_pk_set(meta)
     1114            if not pk_set:
     1115                pk_val = meta.pk.get_pk_value_on_save(self)
     1116                setattr(self, meta.pk.attname, pk_val)
     1117                pk_set = self._is_pk_set(meta)
     1118
    11111119        if not pk_set and (force_update or update_fields):
    11121120            raise ValueError("Cannot force an update in save() with no primary key.")
    11131121        updated = False
  • django/db/models/fields/composite.py

    diff --git a/django/db/models/fields/composite.py b/django/db/models/fields/composite.py
    index 2b196f6d2a..3ab8295dbc 100644
    a b class CompositeAttribute:  
    2828        attnames = self.attnames
    2929        length = len(attnames)
    3030
    31         if values is None:
     31        is_clear_cp = values is None
     32        if is_clear_cp:
    3233            values = (None,) * length
    3334
    3435        if not isinstance(values, (list, tuple)):
    class CompositeAttribute:  
    3839
    3940        for attname, value in zip(attnames, values):
    4041            setattr(instance, attname, value)
     42        instance._state.is_set_composite_pk = is_clear_cp
    4143
    4244
    4345class CompositePrimaryKey(Field):
  • tests/composite_pk/models/tenant.py

    diff --git a/tests/composite_pk/models/tenant.py b/tests/composite_pk/models/tenant.py
    index ac0b3d9715..1c1108c0d3 100644
    a b  
     1import uuid
     2
    13from django.db import models
    24
    35
    class Comment(models.Model):  
    4648
    4749class Post(models.Model):
    4850    pk = models.CompositePrimaryKey("tenant_id", "id")
    49     tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    50     id = models.UUIDField()
     51    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, default=1)
     52    id = models.UUIDField(default=uuid.uuid4)
  • tests/composite_pk/test_create.py

    diff --git a/tests/composite_pk/test_create.py b/tests/composite_pk/test_create.py
    index 7c9925b946..5120324f65 100644
    a b  
    11from django.test import TestCase
    22
    3 from .models import Tenant, User
     3from .models import Tenant, User, Post
    44
    55
    66class CompositePKCreateTests(TestCase):
    class CompositePKCreateTests(TestCase):  
    1414            id=1,
    1515            email="user0001@example.com",
    1616        )
     17        cls.post = Post.objects.create()
     18
     19    def test_save_default_pk_not_set(self):
     20        with self.assertNumQueries(1):
     21            Post().save()
    1722
    1823    def test_create_user(self):
    1924        test_cases = (
  • tests/composite_pk/test_filter.py

    diff --git a/tests/composite_pk/test_filter.py b/tests/composite_pk/test_filter.py
    index 7e361c5925..9fa3e4edb1 100644
    a b class CompositePKFilterTests(TestCase):  
    3737        cls.comment_4 = Comment.objects.create(id=4, user=cls.user_3)
    3838        cls.comment_5 = Comment.objects.create(id=5, user=cls.user_1)
    3939
     40
    4041    def test_filter_and_count_user_by_pk(self):
    4142        test_cases = (
    4243            ({"pk": self.user_1.pk}, 1),
Last edited 2 days ago by mao xin chen (previous) (diff)

in reply to:  2 comment:3 by Csirmaz Bendegúz, 37 hours ago

Replying to mao xin chen:

This is my first time trying to submit code. Please see if it can meet the requirements. If so, I will learn to submit PR. Thanks

Thanks for the patch! Please check the docs on how to contribute. If you open a PR on GitHub we can review your code.

comment:4 by Csirmaz Bendegúz, 36 hours ago

Thanks Jacob!

n default obj.save() conflict
1 yes insert IntegrityError
2 no update & insert update
  1. If pk has a default or db_default, saving a new object with a conflicting pk attempts an insert and raises an IntegrityError
  2. If pk doesn't have a default or db_default, saving a new object with a conflicting pk attempts an update (if no rows affected attempts an insert)

Is that correct?

# 1. Model.id has a default
foo = Model(id=1)
foo.save(). # INSERT
bar = Model(id=1)
bar.save(). # INSERT & IntegrityError

# 2. Model.id doesn't have a default
foo = Model(id=1)
foo.save(). # UPDATE & INSERT
bar = Model(id=1)
bar.save(). # UPDATE
Version 2, edited 36 hours ago by Csirmaz Bendegúz (previous) (next) (diff)

comment:5 by Csirmaz Bendegúz, 36 hours ago

Has patch: set

comment:6 by Sarah Boyce, 34 hours ago

Owner: set to Csirmaz Bendegúz
Status: newassigned

comment:7 by Sarah Boyce, 5 hours ago

Triage Stage: AcceptedReady for checkin

comment:8 by Sarah Boyce, 5 hours ago

Triage Stage: Ready for checkinAccepted
Note: See TracTickets for help on using tickets.
Back to Top