From 95394443bff2cd6c5cb47c752422fe1f391bb43d Mon Sep 17 00:00:00 2001 From: Youngkwang Yang Date: Thu, 18 Dec 2025 13:49:13 +0900 Subject: 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. --- django/core/serializers/xml_serializer.py | 42 +++++++++++++++----- tests/serializers/models/natural.py | 28 +++++++++++++ tests/serializers/test_natural.py | 66 +++++++++++++++++++++++++++++++ 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 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, +) -- cgit v1.3