summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYoungkwang Yang <me@youngkwang.dev>2025-12-18 13:49:13 +0900
committerJacob Walls <jacobtylerwalls@gmail.com>2025-12-22 14:21:01 -0500
commit95394443bff2cd6c5cb47c752422fe1f391bb43d (patch)
tree9569a6075cf7152be47c5163c952fb76c8ce58ac
parent6ee8e9d56c17eb0014e73189950a9e8de2a8ec0e (diff)
Fixed #36786 -- Fixed XML serialization of None values in natural keys.
None values in natural keys were incorrectly serialized as the string "None", causing deserialization to fail for fields like UUIDField.
-rw-r--r--django/core/serializers/xml_serializer.py42
-rw-r--r--tests/serializers/models/natural.py28
-rw-r--r--tests/serializers/test_natural.py66
3 files changed, 127 insertions, 9 deletions
diff --git a/django/core/serializers/xml_serializer.py b/django/core/serializers/xml_serializer.py
index a9fe3cf01d..d43865c257 100644
--- a/django/core/serializers/xml_serializer.py
+++ b/django/core/serializers/xml_serializer.py
@@ -134,7 +134,10 @@ class Serializer(base.Serializer):
# Iterable natural keys are rolled out as subelements
for key_value in natural_key_value:
self.xml.startElement("natural", {})
- self.xml.characters(str(key_value))
+ if key_value is None:
+ self.xml.addQuickElement("None")
+ else:
+ self.xml.characters(str(key_value))
self.xml.endElement("natural")
else:
self.xml.characters(str(related_att))
@@ -160,7 +163,10 @@ class Serializer(base.Serializer):
self.xml.startElement("object", {})
for key_value in natural:
self.xml.startElement("natural", {})
- self.xml.characters(str(key_value))
+ if key_value is None:
+ self.xml.addQuickElement("None")
+ else:
+ self.xml.characters(str(key_value))
self.xml.endElement("natural")
self.xml.endElement("object")
else:
@@ -279,7 +285,11 @@ class Deserializer(base.Deserializer):
if value == base.DEFER_FIELD:
deferred_fields[field] = [
[
- getInnerText(nat_node).strip()
+ (
+ None
+ if nat_node.getElementsByTagName("None")
+ else getInnerText(nat_node).strip()
+ )
for nat_node in obj_node.getElementsByTagName("natural")
]
for obj_node in field_node.getElementsByTagName("object")
@@ -292,7 +302,11 @@ class Deserializer(base.Deserializer):
value = self._handle_fk_field_node(field_node, field)
if value == base.DEFER_FIELD:
deferred_fields[field] = [
- getInnerText(k).strip()
+ (
+ None
+ if k.getElementsByTagName("None")
+ else getInnerText(k).strip()
+ )
for k in field_node.getElementsByTagName("natural")
]
else:
@@ -317,16 +331,21 @@ class Deserializer(base.Deserializer):
Handle a <field> node for a ForeignKey
"""
# Check if there is a child node named 'None', returning None if so.
- if node.getElementsByTagName("None"):
+ natural_keys = node.getElementsByTagName("natural")
+ if node.getElementsByTagName("None") and not natural_keys:
return None
else:
model = field.remote_field.model
if hasattr(model._default_manager, "get_by_natural_key"):
- keys = node.getElementsByTagName("natural")
- if keys:
+ if natural_keys:
# If there are 'natural' subelements, it must be a natural
# key
- field_value = [getInnerText(k).strip() for k in keys]
+ field_value = []
+ for k in natural_keys:
+ if k.getElementsByTagName("None"):
+ field_value.append(None)
+ else:
+ field_value.append(getInnerText(k).strip())
try:
obj = model._default_manager.db_manager(
self.db
@@ -367,7 +386,12 @@ class Deserializer(base.Deserializer):
if keys:
# If there are 'natural' subelements, it must be a natural
# key
- field_value = [getInnerText(k).strip() for k in keys]
+ field_value = []
+ for k in keys:
+ if k.getElementsByTagName("None"):
+ field_value.append(None)
+ else:
+ field_value.append(getInnerText(k).strip())
obj_pk = (
default_manager.db_manager(self.db)
.get_by_natural_key(*field_value)
diff --git a/tests/serializers/models/natural.py b/tests/serializers/models/natural.py
index f32d781a76..3b01385e8f 100644
--- a/tests/serializers/models/natural.py
+++ b/tests/serializers/models/natural.py
@@ -91,3 +91,31 @@ class PostToOptOutSubclassUser(models.Model):
subscribers = models.ManyToManyField(
SubclassNaturalKeyOptOutUser, related_name="subscribed_posts", blank=True
)
+
+
+class NaturalKeyWithNullableFieldManager(models.Manager):
+ def get_by_natural_key(self, name, optional_id):
+ return self.get(name=name, optional_id=optional_id)
+
+
+class NaturalKeyWithNullableField(models.Model):
+ name = models.CharField(max_length=100)
+ optional_id = models.CharField(max_length=100, null=True, blank=True)
+
+ objects = NaturalKeyWithNullableFieldManager()
+
+ class Meta:
+ unique_together = [["name", "optional_id"]]
+
+ def natural_key(self):
+ return (self.name, self.optional_id)
+
+
+class FKToNaturalKeyWithNullable(models.Model):
+ ref = models.ForeignKey(
+ NaturalKeyWithNullableField, on_delete=models.CASCADE, null=True
+ )
+ refs = models.ManyToManyField(
+ NaturalKeyWithNullableField, related_name="m2m_referrers"
+ )
+ data = models.CharField(max_length=100, default="")
diff --git a/tests/serializers/test_natural.py b/tests/serializers/test_natural.py
index 322abeb7fc..e5592f97c8 100644
--- a/tests/serializers/test_natural.py
+++ b/tests/serializers/test_natural.py
@@ -6,8 +6,10 @@ from .models import (
Child,
FKAsPKNoNaturalKey,
FKDataNaturalKey,
+ FKToNaturalKeyWithNullable,
NaturalKeyAnchor,
NaturalKeyThing,
+ NaturalKeyWithNullableField,
NaturalPKWithDefault,
PostToOptOutSubclassUser,
SubclassNaturalKeyOptOutUser,
@@ -280,6 +282,60 @@ def natural_key_opt_out_test(self, format):
)
+def nullable_natural_key_fk_test(self, format):
+ target_with_none = NaturalKeyWithNullableField.objects.create(
+ name="test_none",
+ optional_id=None,
+ )
+ target_with_value = NaturalKeyWithNullableField.objects.create(
+ name="test_value",
+ optional_id="some_id",
+ )
+ fk_to_none = FKToNaturalKeyWithNullable.objects.create(
+ ref=target_with_none,
+ data="points_to_none",
+ )
+ fk_to_value = FKToNaturalKeyWithNullable.objects.create(
+ ref=target_with_value,
+ data="points_to_value",
+ )
+ objects = [target_with_none, target_with_value, fk_to_none, fk_to_value]
+ serialized = serializers.serialize(
+ format,
+ objects,
+ use_natural_foreign_keys=True,
+ use_natural_primary_keys=True,
+ )
+ objs = list(serializers.deserialize(format, serialized))
+ self.assertEqual(objs[2].object.ref_id, target_with_none.pk)
+ self.assertEqual(objs[3].object.ref_id, target_with_value.pk)
+
+
+def nullable_natural_key_m2m_test(self, format):
+ target_with_none = NaturalKeyWithNullableField.objects.create(
+ name="test_none",
+ optional_id=None,
+ )
+ target_with_value = NaturalKeyWithNullableField.objects.create(
+ name="test_value",
+ optional_id="some_id",
+ )
+ m2m_obj = FKToNaturalKeyWithNullable.objects.create(data="m2m_test")
+ m2m_obj.refs.set([target_with_none, target_with_value])
+ objects = [target_with_none, target_with_value, m2m_obj]
+ serialized = serializers.serialize(
+ format,
+ objects,
+ use_natural_foreign_keys=True,
+ use_natural_primary_keys=True,
+ )
+ objs = list(serializers.deserialize(format, serialized))
+ self.assertCountEqual(
+ objs[2].m2m_data["refs"],
+ [target_with_none.pk, target_with_value.pk],
+ )
+
+
# Dynamically register tests for each serializer
register_tests(
NaturalKeySerializerTests,
@@ -319,3 +375,13 @@ register_tests(
"test_%s_natural_key_opt_out",
natural_key_opt_out_test,
)
+register_tests(
+ NaturalKeySerializerTests,
+ "test_%s_nullable_natural_key_fk",
+ nullable_natural_key_fk_test,
+)
+register_tests(
+ NaturalKeySerializerTests,
+ "test_%s_nullable_natural_key_m2m",
+ nullable_natural_key_m2m_test,
+)