summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorrimi0108 <hyerimc858@gmail.com>2025-10-04 13:52:59 +0900
committerJacob Walls <jacobtylerwalls@gmail.com>2025-12-03 15:04:52 -0500
commit93540b34d4ef46f68df2c8bfe90447d0f649a852 (patch)
tree93967647a5590ec653372f38cfb7123166d819c3
parentddb7236b0ae9be3163c90f799fb79396e9f61cc8 (diff)
Fixed #35729 -- Enabled natural key serialization opt-out for subclasses.
Refactored serialization logic to allow models inheriting a natural_key() method (e.g. AbstractBaseUser) to explicitly opt out of natural key serialization by returning an empty tuple from the method. Thanks Jonas Dittrich for the report. Co-authored-by: Jacob Walls <jacobtylerwalls@gmail.com>
-rw-r--r--django/core/serializers/base.py28
-rw-r--r--django/core/serializers/python.py21
-rw-r--r--django/core/serializers/xml_serializer.py33
-rw-r--r--docs/releases/6.1.txt5
-rw-r--r--docs/topics/serialization.txt9
-rw-r--r--tests/serializers/models/natural.py17
-rw-r--r--tests/serializers/test_natural.py35
7 files changed, 119 insertions, 29 deletions
diff --git a/django/core/serializers/base.py b/django/core/serializers/base.py
index efc55981eb..cc9caf9a82 100644
--- a/django/core/serializers/base.py
+++ b/django/core/serializers/base.py
@@ -209,6 +209,34 @@ class Serializer:
if callable(getattr(self.stream, "getvalue", None)):
return self.stream.getvalue()
+ def _resolve_natural_key(self, obj):
+ """Return a natural key tuple for the given object when available."""
+ try:
+ return obj.natural_key()
+ except AttributeError:
+ return None
+
+ def _resolve_fk_natural_key(self, obj, field):
+ """
+ Return the natural key for a ForeignKey's related object, or None if
+ not supported.
+ """
+ if not self._model_supports_natural_key(field.remote_field.model):
+ return None
+
+ related = getattr(obj, field.name, None)
+ try:
+ return related.natural_key()
+ except AttributeError:
+ return None
+
+ def _model_supports_natural_key(self, model):
+ """Return True if the model defines a natural_key() method."""
+ try:
+ return callable(model.natural_key)
+ except AttributeError:
+ return False
+
class Deserializer:
"""
diff --git a/django/core/serializers/python.py b/django/core/serializers/python.py
index 2929874b01..53a73e19e5 100644
--- a/django/core/serializers/python.py
+++ b/django/core/serializers/python.py
@@ -34,7 +34,7 @@ class Serializer(base.Serializer):
def get_dump_object(self, obj):
data = {"model": str(obj._meta)}
- if not self.use_natural_primary_keys or not hasattr(obj, "natural_key"):
+ if not self.use_natural_primary_keys or not self._resolve_natural_key(obj):
data["pk"] = self._value_from_field(obj, obj._meta.pk)
data["fields"] = self._current
return data
@@ -52,26 +52,25 @@ class Serializer(base.Serializer):
self._current[field.name] = self._value_from_field(obj, field)
def handle_fk_field(self, obj, field):
- if self.use_natural_foreign_keys and hasattr(
- field.remote_field.model, "natural_key"
+ if self.use_natural_foreign_keys and (
+ natural_key_value := self._resolve_fk_natural_key(obj, field)
):
- related = getattr(obj, field.name)
- if related:
- value = related.natural_key()
- else:
- value = None
+ value = natural_key_value
else:
value = self._value_from_field(obj, field)
self._current[field.name] = value
def handle_m2m_field(self, obj, field):
if field.remote_field.through._meta.auto_created:
- if self.use_natural_foreign_keys and hasattr(
- field.remote_field.model, "natural_key"
+ if self.use_natural_foreign_keys and self._model_supports_natural_key(
+ field.remote_field.model
):
def m2m_value(value):
- return value.natural_key()
+ if natural := value.natural_key():
+ return natural
+ else:
+ return self._value_from_field(value, value._meta.pk)
def queryset_iterator(obj, field):
attr = getattr(obj, field.name)
diff --git a/django/core/serializers/xml_serializer.py b/django/core/serializers/xml_serializer.py
index f8ec0865a7..910c489a37 100644
--- a/django/core/serializers/xml_serializer.py
+++ b/django/core/serializers/xml_serializer.py
@@ -73,7 +73,7 @@ class Serializer(base.Serializer):
self.indent(1)
attrs = {"model": str(obj._meta)}
- if not self.use_natural_primary_keys or not hasattr(obj, "natural_key"):
+ if not self.use_natural_primary_keys or not self._resolve_natural_key(obj):
obj_pk = obj.pk
if obj_pk is not None:
attrs["pk"] = obj._meta.pk.value_to_string(obj)
@@ -128,14 +128,11 @@ class Serializer(base.Serializer):
self._start_relational_field(field)
related_att = getattr(obj, field.attname)
if related_att is not None:
- if self.use_natural_foreign_keys and hasattr(
- field.remote_field.model, "natural_key"
+ if self.use_natural_foreign_keys and (
+ natural_key_value := self._resolve_fk_natural_key(obj, field)
):
- related = getattr(obj, field.name)
- # If related object has a natural key, use it
- related = related.natural_key()
# Iterable natural keys are rolled out as subelements
- for key_value in related:
+ for key_value in natural_key_value:
self.xml.startElement("natural", {})
self.xml.characters(str(key_value))
self.xml.endElement("natural")
@@ -153,19 +150,21 @@ class Serializer(base.Serializer):
"""
if field.remote_field.through._meta.auto_created:
self._start_relational_field(field)
- if self.use_natural_foreign_keys and hasattr(
- field.remote_field.model, "natural_key"
+ if self.use_natural_foreign_keys and self._model_supports_natural_key(
+ field.remote_field.model
):
# If the objects in the m2m have a natural key, use it
def handle_m2m(value):
- natural = value.natural_key()
- # Iterable natural keys are rolled out as subelements
- self.xml.startElement("object", {})
- for key_value in natural:
- self.xml.startElement("natural", {})
- self.xml.characters(str(key_value))
- self.xml.endElement("natural")
- self.xml.endElement("object")
+ if natural := self._resolve_natural_key(value):
+ # Iterable natural keys are rolled out as subelements
+ self.xml.startElement("object", {})
+ for key_value in natural:
+ self.xml.startElement("natural", {})
+ self.xml.characters(str(key_value))
+ self.xml.endElement("natural")
+ self.xml.endElement("object")
+ else:
+ self.xml.addQuickElement("object", attrs={"pk": str(value.pk)})
def queryset_iterator(obj, field):
attr = getattr(obj, field.name)
diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt
index 1c533f1341..587fec10b3 100644
--- a/docs/releases/6.1.txt
+++ b/docs/releases/6.1.txt
@@ -281,7 +281,10 @@ Security
Serialization
~~~~~~~~~~~~~
-* ...
+* Subclasses of models defining the ``natural_key()`` method can now opt out of
+ natural key serialization by overriding the method to return an empty tuple:
+ ``()``. This ensures primary keys are serialized when using
+ :option:`dumpdata --natural-primary`.
Signals
~~~~~~~
diff --git a/docs/topics/serialization.txt b/docs/topics/serialization.txt
index 2b28f5e15a..f801df0561 100644
--- a/docs/topics/serialization.txt
+++ b/docs/topics/serialization.txt
@@ -638,6 +638,15 @@ command line flags to generate natural keys.
natural keys during serialization, but *not* be able to load those
key values, just don't define the ``get_by_natural_key()`` method.
+Subclasses can opt out of natural key serialization by returning an empty tuple
+(``()``) from ``natural_key()``. This tells the serializer to fall back to
+the standard primary key.
+
+.. versionchanged:: 6.1
+
+ Support for opting out of natural key serialization by returning an empty
+ tuple was added.
+
.. _natural-keys-and-forward-references:
Natural keys and forward references
diff --git a/tests/serializers/models/natural.py b/tests/serializers/models/natural.py
index cfc57b1127..f32d781a76 100644
--- a/tests/serializers/models/natural.py
+++ b/tests/serializers/models/natural.py
@@ -2,6 +2,7 @@
import uuid
+from django.contrib.auth.base_user import AbstractBaseUser
from django.db import models
@@ -74,3 +75,19 @@ class FKAsPKNoNaturalKey(models.Model):
def natural_key(self):
raise NotImplementedError("This method was not expected to be called.")
+
+
+class SubclassNaturalKeyOptOutUser(AbstractBaseUser):
+ email = models.EmailField(unique=False, null=True, blank=True)
+ USERNAME_FIELD = "email"
+
+ def natural_key(self):
+ return ()
+
+
+class PostToOptOutSubclassUser(models.Model):
+ author = models.ForeignKey(SubclassNaturalKeyOptOutUser, on_delete=models.CASCADE)
+ title = models.CharField(max_length=100)
+ subscribers = models.ManyToManyField(
+ SubclassNaturalKeyOptOutUser, related_name="subscribed_posts", blank=True
+ )
diff --git a/tests/serializers/test_natural.py b/tests/serializers/test_natural.py
index b5b35708c6..322abeb7fc 100644
--- a/tests/serializers/test_natural.py
+++ b/tests/serializers/test_natural.py
@@ -9,6 +9,8 @@ from .models import (
NaturalKeyAnchor,
NaturalKeyThing,
NaturalPKWithDefault,
+ PostToOptOutSubclassUser,
+ SubclassNaturalKeyOptOutUser,
)
from .tests import register_tests
@@ -250,6 +252,34 @@ def fk_as_pk_natural_key_not_called(self, format):
self.assertEqual(obj.object.pk, o1.pk)
+def natural_key_opt_out_test(self, format):
+ """
+ When a subclass of AbstractBaseUser opts out of natural key serialization
+ by returning an empty tuple, both FK and M2M relations serialize as
+ integer PKs and can be deserialized without error.
+ """
+ user1 = SubclassNaturalKeyOptOutUser.objects.create(email="user1@example.com")
+ user2 = SubclassNaturalKeyOptOutUser.objects.create(email="user2@example.com")
+
+ post = PostToOptOutSubclassUser.objects.create(
+ author=user1, title="Post 2 (Subclass Opt-out)"
+ )
+ post.subscribers.add(user1, user2)
+
+ user_data = serializers.serialize(format, [user1], use_natural_primary_keys=True)
+ post_data = serializers.serialize(format, [post], use_natural_foreign_keys=True)
+
+ list(serializers.deserialize(format, user_data))
+ deserialized_posts = list(serializers.deserialize(format, post_data))
+
+ post_obj = deserialized_posts[0].object
+ self.assertEqual(user1.email, post_obj.author.email)
+ self.assertEqual(
+ sorted([user1.email, user2.email]),
+ sorted(post_obj.subscribers.values_list("email", flat=True)),
+ )
+
+
# Dynamically register tests for each serializer
register_tests(
NaturalKeySerializerTests,
@@ -284,3 +314,8 @@ register_tests(
"test_%s_fk_as_pk_natural_key_not_called",
fk_as_pk_natural_key_not_called,
)
+register_tests(
+ NaturalKeySerializerTests,
+ "test_%s_natural_key_opt_out",
+ natural_key_opt_out_test,
+)