Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions django/core/serializers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,34 @@ def getvalue(self):
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:
"""
Expand Down
21 changes: 10 additions & 11 deletions django/core/serializers/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def end_object(self, obj):

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
Expand All @@ -52,26 +52,25 @@ def handle_field(self, obj, field):
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)
Expand Down
33 changes: 16 additions & 17 deletions django/core/serializers/xml_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def start_object(self, obj):

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)
Expand Down Expand Up @@ -128,14 +128,11 @@ def handle_fk_field(self, obj, field):
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")
Expand All @@ -153,19 +150,21 @@ def handle_m2m_field(self, obj, field):
"""
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)
Expand Down
59 changes: 29 additions & 30 deletions django/test/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,14 @@ def run(self, result):
"""
self.initialize_suite()
counter = multiprocessing.Value(ctypes.c_int, 0)
pool = multiprocessing.Pool(
args = [
(self.runner_class, index, subsuite, self.failfast, self.buffer)
for index, subsuite in enumerate(self.subsuites)
]
# Don't buffer in the main process to avoid error propagation issues.
result.buffer = False

with multiprocessing.Pool(
processes=self.processes,
initializer=functools.partial(_safe_init_worker, self.init_worker.__func__),
initargs=[
Expand All @@ -579,38 +586,30 @@ def run(self, result):
self.debug_mode,
self.used_aliases,
],
)
args = [
(self.runner_class, index, subsuite, self.failfast, self.buffer)
for index, subsuite in enumerate(self.subsuites)
]
# Don't buffer in the main process to avoid error propagation issues.
result.buffer = False

test_results = pool.imap_unordered(self.run_subsuite.__func__, args)

while True:
if result.shouldStop:
pool.terminate()
break

try:
subsuite_index, events = test_results.next(timeout=0.1)
except multiprocessing.TimeoutError as err:
if counter.value < 0:
err.add_note("ERROR: _init_worker failed, see prior traceback")
) as pool:
test_results = pool.imap_unordered(self.run_subsuite.__func__, args)

while True:
if result.shouldStop:
pool.terminate()
break

try:
subsuite_index, events = test_results.next(timeout=0.1)
except multiprocessing.TimeoutError as err:
if counter.value < 0:
err.add_note("ERROR: _init_worker failed, see prior traceback")
raise
continue
except StopIteration:
pool.close()
raise
continue
except StopIteration:
pool.close()
break
break

tests = list(self.subsuites[subsuite_index])
for event in events:
self.handle_event(result, tests, event)
tests = list(self.subsuites[subsuite_index])
for event in events:
self.handle_event(result, tests, event)

pool.join()
pool.join()

return result

Expand Down
4 changes: 0 additions & 4 deletions docs/releases/6.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -478,10 +478,6 @@ Miscellaneous
* The :ref:`JSON <serialization-formats-json>` serializer now writes a newline
at the end of the output, even without the ``indent`` option set.

* The undocumented ``django.utils.http.parse_header_parameters()`` function is
refactored to use Python's :class:`email.message.Message` for parsing.
Input headers exceeding 10000 characters will now raise :exc:`ValueError`.

* The minimum supported version of ``asgiref`` is increased from 3.8.1 to
3.9.1.

Expand Down
5 changes: 4 additions & 1 deletion docs/releases/6.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~
Expand Down
9 changes: 9 additions & 0 deletions docs/topics/serialization.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions tests/serializers/models/natural.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import uuid

from django.contrib.auth.base_user import AbstractBaseUser
from django.db import models


Expand Down Expand Up @@ -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
)
35 changes: 35 additions & 0 deletions tests/serializers/test_natural.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
NaturalKeyAnchor,
NaturalKeyThing,
NaturalPKWithDefault,
PostToOptOutSubclassUser,
SubclassNaturalKeyOptOutUser,
)
from .tests import register_tests

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -284,3 +314,8 @@ def fk_as_pk_natural_key_not_called(self, format):
"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,
)
5 changes: 2 additions & 3 deletions tests/test_runner/test_parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,8 @@ def fake_next(*args, **kwargs):
test_result.shouldStop = True
return (0, remote_result.events)

mock_pool.return_value.imap_unordered.return_value = unittest.mock.Mock(
next=fake_next
)
mock_imap = mock_pool.return_value.__enter__.return_value.imap_unordered
mock_imap.return_value = unittest.mock.Mock(next=fake_next)
pts.run(test_result)

self.assertIn("ValueError: woops", test_result.errors[0][1])