From 13a031e3878b7872801ec1351f7203601f1aac5a Mon Sep 17 00:00:00 2001 From: Adel Khayata Date: Wed, 17 Jun 2026 16:52:44 +0400 Subject: [PATCH 1/7] Avoid N+1 queries in PrimaryKeyRelatedField(many=True) validation ManyRelatedField.to_internal_value resolved each related object with a separate child_relation.to_internal_value() call, so validating a list of N primary keys issued N SELECT queries (#9607). Add an opt-in to_internal_value_bulk() hook on RelatedField (defaulting to the existing per-item loop, so SlugRelatedField, HyperlinkedRelatedField and custom relations are unchanged) and override it on PrimaryKeyRelatedField to resolve every pk with a single in_bulk() query. Per-item error semantics (incorrect_type / does_not_exist), input ordering, duplicate handling, the queryset filter and pk_field transforms are all preserved; a type the backend cannot compare falls back to the per-item path so the offending item still raises the same error. --- rest_framework/relations.py | 36 ++++++++++++++++++++++--- tests/test_relations_pk.py | 53 +++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/rest_framework/relations.py b/rest_framework/relations.py index 4409bce77c..05b445dc38 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -169,6 +169,12 @@ def get_queryset(self): def use_pk_only_optimization(self): return False + def to_internal_value_bulk(self, data): + # Default (un-optimized) bulk conversion: delegate to per-item + # `to_internal_value`. Subclasses may override to batch DB access. + # Used by `ManyRelatedField` to avoid N+1 queries. + return [self.to_internal_value(item) for item in data] + def get_attribute(self, instance): if self.use_pk_only_optimization() and self.source_attrs: # Optimized case, return a mock object only containing the pk attribute. @@ -262,6 +268,31 @@ def to_internal_value(self, data): except (TypeError, ValueError): self.fail('incorrect_type', data_type=type(data).__name__) + def to_internal_value_bulk(self, data): + # Resolve every pk with a single query instead of one `get()` per item. + # Per-item error semantics (incorrect_type / does_not_exist), input + # ordering, and duplicates are all preserved. + pks = [] + for item in data: + value = item + if self.pk_field is not None: + value = self.pk_field.to_internal_value(value) + if isinstance(value, bool): + self.fail('incorrect_type', data_type=type(item).__name__) + pks.append(value) + try: + objects = self.get_queryset().in_bulk(pks) + except (TypeError, ValueError): + # A pk had a type the backend can't compare; fall back so the + # offending item raises the same per-item error as before. + return [self.to_internal_value(item) for item in data] + result = [] + for pk in pks: + if pk not in objects: + self.fail('does_not_exist', pk_value=pk) + result.append(objects[pk]) + return result + def to_representation(self, value): if self.pk_field is not None: return self.pk_field.to_representation(value.pk) @@ -524,10 +555,7 @@ def to_internal_value(self, data): if not self.allow_empty and len(data) == 0: self.fail('empty') - return [ - self.child_relation.to_internal_value(item) - for item in data - ] + return self.child_relation.to_internal_value_bulk(data) def get_attribute(self, instance): # Can't have any relationships if not created diff --git a/tests/test_relations_pk.py b/tests/test_relations_pk.py index 0769defebd..109024766e 100644 --- a/tests/test_relations_pk.py +++ b/tests/test_relations_pk.py @@ -227,6 +227,59 @@ def test_data_cannot_be_accessed_prior_to_is_valid(self): serializer.data +class PKManyRelatedFieldBulkValidationTests(TestCase): + """`PrimaryKeyRelatedField(many=True)` should resolve all pks in a single + query rather than one query per item (regression test for #9607).""" + + def setUp(self): + for idx in range(1, 6): + ManyToManyTarget(name='target-%d' % idx).save() + + def _field(self): + field = serializers.PrimaryKeyRelatedField( + queryset=ManyToManyTarget.objects.all(), many=True) + field.bind('targets', serializers.Serializer()) + return field + + def test_validation_uses_single_query(self): + field = self._field() + with self.assertNumQueries(1): + field.run_validation([1, 2, 3, 4, 5]) + + def test_order_and_duplicates_preserved(self): + field = self._field() + result = field.run_validation([3, 1, 1, 2]) + assert [obj.pk for obj in result] == [3, 1, 1, 2] + + def test_does_not_exist_error(self): + field = self._field() + with pytest.raises(serializers.ValidationError) as exc_info: + field.run_validation([1, 99]) + assert exc_info.value.detail[0].code == 'does_not_exist' + + def test_incorrect_type_error(self): + field = self._field() + with pytest.raises(serializers.ValidationError) as exc_info: + field.run_validation(['not-a-pk']) + assert exc_info.value.detail[0].code == 'incorrect_type' + + def test_queryset_filtering_is_respected(self): + field = serializers.PrimaryKeyRelatedField( + queryset=ManyToManyTarget.objects.exclude(pk=2), many=True) + field.bind('targets', serializers.Serializer()) + with pytest.raises(serializers.ValidationError) as exc_info: + field.run_validation([1, 2]) + assert exc_info.value.detail[0].code == 'does_not_exist' + + def test_pk_field_transform_is_applied(self): + field = serializers.PrimaryKeyRelatedField( + queryset=ManyToManyTarget.objects.all(), many=True, + pk_field=serializers.IntegerField()) + field.bind('targets', serializers.Serializer()) + result = field.run_validation(['1', '2']) + assert [obj.pk for obj in result] == [1, 2] + + @pytest.mark.usefixtures("reset_sequences") class PKForeignKeyTests(TestCase): def setUp(self): From 31d9e582e1b0d273c23a161d10e140b7d050118c Mon Sep 17 00:00:00 2001 From: Adel Khayata Date: Wed, 17 Jun 2026 17:05:01 +0400 Subject: [PATCH 2/7] Coerce pk type and fix test isolation in bulk pk validation Handle string primary keys (e.g. from HTML form input): in_bulk() keys its result by the database pk type, so a string "1" must be coerced via the pk field's get_prep_value() before the membership check, exactly as queryset.get(pk=...) does. Without this, string pks raised a spurious does_not_exist error. Also make the regression tests rely on the pks actually created in setUp rather than hard-coding 1..5, which is not guaranteed across backends/test ordering, and add an explicit string-pk test. --- rest_framework/relations.py | 17 ++++++++++++---- tests/test_relations_pk.py | 39 +++++++++++++++++++++++-------------- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/rest_framework/relations.py b/rest_framework/relations.py index 05b445dc38..d6a1e094a3 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -272,19 +272,28 @@ def to_internal_value_bulk(self, data): # Resolve every pk with a single query instead of one `get()` per item. # Per-item error semantics (incorrect_type / does_not_exist), input # ordering, and duplicates are all preserved. + queryset = self.get_queryset() + model_pk = queryset.model._meta.pk pks = [] for item in data: value = item if self.pk_field is not None: value = self.pk_field.to_internal_value(value) - if isinstance(value, bool): + try: + if isinstance(value, bool): + raise TypeError + # Coerce to the pk's Python type (e.g. "1" -> 1) so the lookup + # below matches the keys returned by `in_bulk()`, exactly as + # `queryset.get(pk=value)` would have. + value = model_pk.get_prep_value(value) + except (TypeError, ValueError): self.fail('incorrect_type', data_type=type(item).__name__) pks.append(value) try: - objects = self.get_queryset().in_bulk(pks) + objects = queryset.in_bulk(pks) except (TypeError, ValueError): - # A pk had a type the backend can't compare; fall back so the - # offending item raises the same per-item error as before. + # queryset doesn't support in_bulk (e.g. distinct/sliced); fall + # back to the per-item path so behaviour is unchanged. return [self.to_internal_value(item) for item in data] result = [] for pk in pks: diff --git a/tests/test_relations_pk.py b/tests/test_relations_pk.py index 109024766e..f8f7979e40 100644 --- a/tests/test_relations_pk.py +++ b/tests/test_relations_pk.py @@ -232,29 +232,40 @@ class PKManyRelatedFieldBulkValidationTests(TestCase): query rather than one query per item (regression test for #9607).""" def setUp(self): - for idx in range(1, 6): - ManyToManyTarget(name='target-%d' % idx).save() + self.pks = [ + ManyToManyTarget.objects.create(name='target-%d' % idx).pk + for idx in range(1, 6) + ] - def _field(self): - field = serializers.PrimaryKeyRelatedField( - queryset=ManyToManyTarget.objects.all(), many=True) + def _field(self, queryset=None): + if queryset is None: + queryset = ManyToManyTarget.objects.all() + field = serializers.PrimaryKeyRelatedField(queryset=queryset, many=True) field.bind('targets', serializers.Serializer()) return field def test_validation_uses_single_query(self): field = self._field() with self.assertNumQueries(1): - field.run_validation([1, 2, 3, 4, 5]) + field.run_validation(self.pks) def test_order_and_duplicates_preserved(self): field = self._field() - result = field.run_validation([3, 1, 1, 2]) - assert [obj.pk for obj in result] == [3, 1, 1, 2] + order = [self.pks[2], self.pks[0], self.pks[0], self.pks[1]] + result = field.run_validation(order) + assert [obj.pk for obj in result] == order + + def test_string_pks_are_accepted(self): + # HTML form input arrives as strings; must match int pks (#9607). + field = self._field() + result = field.run_validation([str(pk) for pk in self.pks]) + assert [obj.pk for obj in result] == self.pks def test_does_not_exist_error(self): field = self._field() + missing = max(self.pks) + 1000 with pytest.raises(serializers.ValidationError) as exc_info: - field.run_validation([1, 99]) + field.run_validation([self.pks[0], missing]) assert exc_info.value.detail[0].code == 'does_not_exist' def test_incorrect_type_error(self): @@ -264,11 +275,9 @@ def test_incorrect_type_error(self): assert exc_info.value.detail[0].code == 'incorrect_type' def test_queryset_filtering_is_respected(self): - field = serializers.PrimaryKeyRelatedField( - queryset=ManyToManyTarget.objects.exclude(pk=2), many=True) - field.bind('targets', serializers.Serializer()) + field = self._field(ManyToManyTarget.objects.exclude(pk=self.pks[1])) with pytest.raises(serializers.ValidationError) as exc_info: - field.run_validation([1, 2]) + field.run_validation([self.pks[0], self.pks[1]]) assert exc_info.value.detail[0].code == 'does_not_exist' def test_pk_field_transform_is_applied(self): @@ -276,8 +285,8 @@ def test_pk_field_transform_is_applied(self): queryset=ManyToManyTarget.objects.all(), many=True, pk_field=serializers.IntegerField()) field.bind('targets', serializers.Serializer()) - result = field.run_validation(['1', '2']) - assert [obj.pk for obj in result] == [1, 2] + result = field.run_validation([str(self.pks[0]), str(self.pks[1])]) + assert [obj.pk for obj in result] == [self.pks[0], self.pks[1]] @pytest.mark.usefixtures("reset_sequences") From 9c03f8882ba3f29a509675daa4917fba8f1dd60c Mon Sep 17 00:00:00 2001 From: Adel Khayata Date: Wed, 17 Jun 2026 17:10:58 +0400 Subject: [PATCH 3/7] Use US spelling in comment (codespell) --- rest_framework/relations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/relations.py b/rest_framework/relations.py index d6a1e094a3..3e17616b80 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -293,7 +293,7 @@ def to_internal_value_bulk(self, data): objects = queryset.in_bulk(pks) except (TypeError, ValueError): # queryset doesn't support in_bulk (e.g. distinct/sliced); fall - # back to the per-item path so behaviour is unchanged. + # back to the per-item path so behavior is unchanged. return [self.to_internal_value(item) for item in data] result = [] for pk in pks: From b0a437d032f357748c802a6a76499d56b9caf310 Mon Sep 17 00:00:00 2001 From: Adel Khayata Date: Wed, 17 Jun 2026 18:32:26 +0400 Subject: [PATCH 4/7] Address review: error-detail parity and non-related child fallback - Report `incorrect_type` / `does_not_exist` details using the post-`pk_field` value (matching the per-item `to_internal_value` path) instead of the raw input or the pk-coerced lookup key. With a type-changing `pk_field` (e.g. BooleanField) the bulk path previously reported a different `data_type`. - `ManyRelatedField.to_internal_value` now falls back to the per-item loop when the child field has no `to_internal_value_bulk`, so wrapping a non-RelatedField child no longer raises AttributeError. Adds regression tests for both. --- rest_framework/relations.py | 31 +++++++++++++++++++++---------- tests/test_relations_pk.py | 22 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/rest_framework/relations.py b/rest_framework/relations.py index 3e17616b80..13669007f7 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -274,7 +274,10 @@ def to_internal_value_bulk(self, data): # ordering, and duplicates are all preserved. queryset = self.get_queryset() model_pk = queryset.model._meta.pk - pks = [] + # Each entry is (lookup_key, value): `value` mirrors the per-item path + # (post-`pk_field`) and is used for error details, while `lookup_key` + # is the pk-typed value used to match `in_bulk()` results. + entries = [] for item in data: value = item if self.pk_field is not None: @@ -285,21 +288,21 @@ def to_internal_value_bulk(self, data): # Coerce to the pk's Python type (e.g. "1" -> 1) so the lookup # below matches the keys returned by `in_bulk()`, exactly as # `queryset.get(pk=value)` would have. - value = model_pk.get_prep_value(value) + lookup_key = model_pk.get_prep_value(value) except (TypeError, ValueError): - self.fail('incorrect_type', data_type=type(item).__name__) - pks.append(value) + self.fail('incorrect_type', data_type=type(value).__name__) + entries.append((lookup_key, value)) try: - objects = queryset.in_bulk(pks) + objects = queryset.in_bulk([lookup_key for lookup_key, _ in entries]) except (TypeError, ValueError): # queryset doesn't support in_bulk (e.g. distinct/sliced); fall # back to the per-item path so behavior is unchanged. return [self.to_internal_value(item) for item in data] result = [] - for pk in pks: - if pk not in objects: - self.fail('does_not_exist', pk_value=pk) - result.append(objects[pk]) + for lookup_key, value in entries: + if lookup_key not in objects: + self.fail('does_not_exist', pk_value=value) + result.append(objects[lookup_key]) return result def to_representation(self, value): @@ -564,7 +567,15 @@ def to_internal_value(self, data): if not self.allow_empty and len(data) == 0: self.fail('empty') - return self.child_relation.to_internal_value_bulk(data) + # `to_internal_value_bulk` is defined on `RelatedField`; fall back to + # the per-item loop for any other child field type. + bulk = getattr(self.child_relation, 'to_internal_value_bulk', None) + if bulk is not None: + return bulk(data) + return [ + self.child_relation.to_internal_value(item) + for item in data + ] def get_attribute(self, instance): # Can't have any relationships if not created diff --git a/tests/test_relations_pk.py b/tests/test_relations_pk.py index f8f7979e40..92f8d3d3ab 100644 --- a/tests/test_relations_pk.py +++ b/tests/test_relations_pk.py @@ -288,6 +288,28 @@ def test_pk_field_transform_is_applied(self): result = field.run_validation([str(self.pks[0]), str(self.pks[1])]) assert [obj.pk for obj in result] == [self.pks[0], self.pks[1]] + def test_error_details_match_per_item_with_pk_field(self): + # The bulk path must report the same incorrect_type detail as the + # per-item path, i.e. the type *after* pk_field transformation. + child = serializers.PrimaryKeyRelatedField( + queryset=ManyToManyTarget.objects.all(), + pk_field=serializers.BooleanField()) + child.bind('targets', serializers.Serializer()) + with pytest.raises(serializers.ValidationError) as per_item: + child.to_internal_value('true') + with pytest.raises(serializers.ValidationError) as bulk: + child.to_internal_value_bulk(['true']) + assert str(bulk.value.detail[0]) == str(per_item.value.detail[0]) + assert 'bool' in str(bulk.value.detail[0]) + + def test_many_related_field_with_non_related_child(self): + # ManyRelatedField may wrap a plain field that has no + # `to_internal_value_bulk`; it must fall back to per-item conversion. + field = serializers.ManyRelatedField( + child_relation=serializers.IntegerField()) + field.bind('values', serializers.Serializer()) + assert field.to_internal_value([1, 2, 3]) == [1, 2, 3] + @pytest.mark.usefixtures("reset_sequences") class PKForeignKeyTests(TestCase): From 8b73670b3a2a37afb38a418f10ddb9e5d35d7d3f Mon Sep 17 00:00:00 2001 From: Adel Khayata Date: Wed, 2 Sep 2026 14:18:33 +0400 Subject: [PATCH 5/7] Collect all invalid pks in PrimaryKeyRelatedField bulk validation. Report every incorrect_type, does_not_exist, and pk_field error in one index-keyed ValidationError, matching ListField, instead of failing on the first item. Co-authored-by: Cursor --- rest_framework/relations.py | 65 ++++++++++++++++++++++++++----------- tests/test_relations_pk.py | 61 +++++++++++++++++++++++++++++++--- 2 files changed, 103 insertions(+), 23 deletions(-) diff --git a/rest_framework/relations.py b/rest_framework/relations.py index 13669007f7..407629c841 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -10,6 +10,7 @@ from django.utils.encoding import smart_str, uri_to_iri from django.utils.translation import gettext_lazy as _ +from rest_framework.exceptions import ValidationError from rest_framework.fields import ( Field, SkipField, empty, get_attribute, is_simple_callable, iter_options ) @@ -270,18 +271,24 @@ def to_internal_value(self, data): def to_internal_value_bulk(self, data): # Resolve every pk with a single query instead of one `get()` per item. - # Per-item error semantics (incorrect_type / does_not_exist), input - # ordering, and duplicates are all preserved. + # Collect per-item errors (incorrect_type / does_not_exist / pk_field) + # keyed by index, matching ListField.run_child_validation. Input + # ordering and duplicates are preserved. queryset = self.get_queryset() model_pk = queryset.model._meta.pk - # Each entry is (lookup_key, value): `value` mirrors the per-item path - # (post-`pk_field`) and is used for error details, while `lookup_key` - # is the pk-typed value used to match `in_bulk()` results. + # Each entry is (idx, lookup_key, value): `value` mirrors the per-item + # path (post-`pk_field`) and is used for error details, while + # `lookup_key` is the pk-typed value used to match `in_bulk()` results. + errors = {} entries = [] - for item in data: - value = item - if self.pk_field is not None: - value = self.pk_field.to_internal_value(value) + for idx, item in enumerate(data): + try: + value = item + if self.pk_field is not None: + value = self.pk_field.to_internal_value(value) + except ValidationError as exc: + errors[idx] = exc.detail + continue try: if isinstance(value, bool): raise TypeError @@ -290,20 +297,40 @@ def to_internal_value_bulk(self, data): # `queryset.get(pk=value)` would have. lookup_key = model_pk.get_prep_value(value) except (TypeError, ValueError): - self.fail('incorrect_type', data_type=type(value).__name__) - entries.append((lookup_key, value)) + try: + self.fail( + 'incorrect_type', data_type=type(value).__name__ + ) + except ValidationError as exc: + errors[idx] = exc.detail + continue + entries.append((idx, lookup_key, value)) + lookup_keys = [lookup_key for _, lookup_key, _ in entries] try: - objects = queryset.in_bulk([lookup_key for lookup_key, _ in entries]) + objects = queryset.in_bulk(lookup_keys) if lookup_keys else {} except (TypeError, ValueError): # queryset doesn't support in_bulk (e.g. distinct/sliced); fall - # back to the per-item path so behavior is unchanged. - return [self.to_internal_value(item) for item in data] - result = [] - for lookup_key, value in entries: + # back to a collecting per-item loop so mixed lists still report + # every invalid item. + errors = {} + result = [] + for idx, item in enumerate(data): + try: + result.append(self.to_internal_value(item)) + except ValidationError as exc: + errors[idx] = exc.detail + if errors: + raise ValidationError(errors) + return result + for idx, lookup_key, value in entries: if lookup_key not in objects: - self.fail('does_not_exist', pk_value=value) - result.append(objects[lookup_key]) - return result + try: + self.fail('does_not_exist', pk_value=value) + except ValidationError as exc: + errors[idx] = exc.detail + if errors: + raise ValidationError(errors) + return [objects[lookup_key] for _, lookup_key, _ in entries] def to_representation(self, value): if self.pk_field is not None: diff --git a/tests/test_relations_pk.py b/tests/test_relations_pk.py index 92f8d3d3ab..76dfe32367 100644 --- a/tests/test_relations_pk.py +++ b/tests/test_relations_pk.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import pytest from django.test import TestCase @@ -266,19 +268,23 @@ def test_does_not_exist_error(self): missing = max(self.pks) + 1000 with pytest.raises(serializers.ValidationError) as exc_info: field.run_validation([self.pks[0], missing]) - assert exc_info.value.detail[0].code == 'does_not_exist' + detail = exc_info.value.detail + assert 0 not in detail + assert detail[1][0].code == 'does_not_exist' def test_incorrect_type_error(self): field = self._field() with pytest.raises(serializers.ValidationError) as exc_info: field.run_validation(['not-a-pk']) - assert exc_info.value.detail[0].code == 'incorrect_type' + assert exc_info.value.detail[0][0].code == 'incorrect_type' def test_queryset_filtering_is_respected(self): field = self._field(ManyToManyTarget.objects.exclude(pk=self.pks[1])) with pytest.raises(serializers.ValidationError) as exc_info: field.run_validation([self.pks[0], self.pks[1]]) - assert exc_info.value.detail[0].code == 'does_not_exist' + detail = exc_info.value.detail + assert 0 not in detail + assert detail[1][0].code == 'does_not_exist' def test_pk_field_transform_is_applied(self): field = serializers.PrimaryKeyRelatedField( @@ -299,7 +305,7 @@ def test_error_details_match_per_item_with_pk_field(self): child.to_internal_value('true') with pytest.raises(serializers.ValidationError) as bulk: child.to_internal_value_bulk(['true']) - assert str(bulk.value.detail[0]) == str(per_item.value.detail[0]) + assert bulk.value.detail[0] == per_item.value.detail assert 'bool' in str(bulk.value.detail[0]) def test_many_related_field_with_non_related_child(self): @@ -310,6 +316,53 @@ def test_many_related_field_with_non_related_child(self): field.bind('values', serializers.Serializer()) assert field.to_internal_value([1, 2, 3]) == [1, 2, 3] + def test_collects_mixed_errors_in_one_query(self): + field = self._field() + missing = max(self.pks) + 1000 + with self.assertNumQueries(1): + with pytest.raises(serializers.ValidationError) as exc_info: + field.run_validation([missing, 'not-a-pk', self.pks[0]]) + detail = exc_info.value.detail + assert detail[0][0].code == 'does_not_exist' + assert detail[1][0].code == 'incorrect_type' + assert 2 not in detail + + def test_duplicate_invalid_pks_report_each_index(self): + field = self._field() + missing = max(self.pks) + 1000 + with pytest.raises(serializers.ValidationError) as exc_info: + field.run_validation([missing, self.pks[0], missing]) + detail = exc_info.value.detail + assert detail[0][0].code == 'does_not_exist' + assert 1 not in detail + assert detail[2][0].code == 'does_not_exist' + + def test_in_bulk_fallback_collects_errors(self): + # in_bulk() raises TypeError on sliced querysets; inject that so + # the fallback's per-item get() can still resolve valid pks. + field = self._field() + missing = max(self.pks) + 1000 + with patch('django.db.models.query.QuerySet.in_bulk', + side_effect=TypeError): + with pytest.raises(serializers.ValidationError) as exc_info: + field.run_validation([self.pks[0], missing, 'not-a-pk']) + detail = exc_info.value.detail + assert 0 not in detail + assert detail[1][0].code == 'does_not_exist' + assert detail[2][0].code == 'incorrect_type' + + def test_pk_field_validation_error_is_collected(self): + field = serializers.PrimaryKeyRelatedField( + queryset=ManyToManyTarget.objects.all(), many=True, + pk_field=serializers.IntegerField()) + field.bind('targets', serializers.Serializer()) + with self.assertNumQueries(1): + with pytest.raises(serializers.ValidationError) as exc_info: + field.run_validation([self.pks[0], 'not-a-number']) + detail = exc_info.value.detail + assert 0 not in detail + assert 1 in detail + @pytest.mark.usefixtures("reset_sequences") class PKForeignKeyTests(TestCase): From 13563f3db9d7f377902723b908b22424c77ca40d Mon Sep 17 00:00:00 2001 From: Adel Khayata Date: Wed, 2 Sep 2026 17:47:12 +0400 Subject: [PATCH 6/7] Use PrimaryKeyManyRelatedField via many_init instead of a bulk hook. Override PrimaryKeyRelatedField.many_init so many=True builds a dedicated ManyRelatedField subclass with in_bulk validation. Removes to_internal_value_bulk from RelatedField and leaves Slug/Hyperlinked on the default path. Co-authored-by: Cursor --- rest_framework/relations.py | 162 ++++++++++++++++++---------------- rest_framework/serializers.py | 3 +- tests/test_relations_pk.py | 15 +++- 3 files changed, 102 insertions(+), 78 deletions(-) diff --git a/rest_framework/relations.py b/rest_framework/relations.py index 407629c841..e4b6310e47 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -170,12 +170,6 @@ def get_queryset(self): def use_pk_only_optimization(self): return False - def to_internal_value_bulk(self, data): - # Default (un-optimized) bulk conversion: delegate to per-item - # `to_internal_value`. Subclasses may override to batch DB access. - # Used by `ManyRelatedField` to avoid N+1 queries. - return [self.to_internal_value(item) for item in data] - def get_attribute(self, instance): if self.use_pk_only_optimization() and self.source_attrs: # Optimized case, return a mock object only containing the pk attribute. @@ -253,6 +247,16 @@ def __init__(self, **kwargs): self.pk_field = kwargs.pop('pk_field', None) super().__init__(**kwargs) + @classmethod + def many_init(cls, *args, **kwargs): + # Use PrimaryKeyManyRelatedField so many=True validates with one + # in_bulk() query. Slug/Hyperlinked keep RelatedField.many_init. + list_kwargs = {'child_relation': cls(*args, **kwargs)} + for key in kwargs: + if key in MANY_RELATION_KWARGS: + list_kwargs[key] = kwargs[key] + return PrimaryKeyManyRelatedField(**list_kwargs) + def use_pk_only_optimization(self): return True @@ -269,69 +273,6 @@ def to_internal_value(self, data): except (TypeError, ValueError): self.fail('incorrect_type', data_type=type(data).__name__) - def to_internal_value_bulk(self, data): - # Resolve every pk with a single query instead of one `get()` per item. - # Collect per-item errors (incorrect_type / does_not_exist / pk_field) - # keyed by index, matching ListField.run_child_validation. Input - # ordering and duplicates are preserved. - queryset = self.get_queryset() - model_pk = queryset.model._meta.pk - # Each entry is (idx, lookup_key, value): `value` mirrors the per-item - # path (post-`pk_field`) and is used for error details, while - # `lookup_key` is the pk-typed value used to match `in_bulk()` results. - errors = {} - entries = [] - for idx, item in enumerate(data): - try: - value = item - if self.pk_field is not None: - value = self.pk_field.to_internal_value(value) - except ValidationError as exc: - errors[idx] = exc.detail - continue - try: - if isinstance(value, bool): - raise TypeError - # Coerce to the pk's Python type (e.g. "1" -> 1) so the lookup - # below matches the keys returned by `in_bulk()`, exactly as - # `queryset.get(pk=value)` would have. - lookup_key = model_pk.get_prep_value(value) - except (TypeError, ValueError): - try: - self.fail( - 'incorrect_type', data_type=type(value).__name__ - ) - except ValidationError as exc: - errors[idx] = exc.detail - continue - entries.append((idx, lookup_key, value)) - lookup_keys = [lookup_key for _, lookup_key, _ in entries] - try: - objects = queryset.in_bulk(lookup_keys) if lookup_keys else {} - except (TypeError, ValueError): - # queryset doesn't support in_bulk (e.g. distinct/sliced); fall - # back to a collecting per-item loop so mixed lists still report - # every invalid item. - errors = {} - result = [] - for idx, item in enumerate(data): - try: - result.append(self.to_internal_value(item)) - except ValidationError as exc: - errors[idx] = exc.detail - if errors: - raise ValidationError(errors) - return result - for idx, lookup_key, value in entries: - if lookup_key not in objects: - try: - self.fail('does_not_exist', pk_value=value) - except ValidationError as exc: - errors[idx] = exc.detail - if errors: - raise ValidationError(errors) - return [objects[lookup_key] for _, lookup_key, _ in entries] - def to_representation(self, value): if self.pk_field is not None: return self.pk_field.to_representation(value.pk) @@ -594,11 +535,6 @@ def to_internal_value(self, data): if not self.allow_empty and len(data) == 0: self.fail('empty') - # `to_internal_value_bulk` is defined on `RelatedField`; fall back to - # the per-item loop for any other child field type. - bulk = getattr(self.child_relation, 'to_internal_value_bulk', None) - if bulk is not None: - return bulk(data) return [ self.child_relation.to_internal_value(item) for item in data @@ -658,3 +594,81 @@ def iter_options(self): cutoff=self.html_cutoff, cutoff_text=self.html_cutoff_text ) + + +class PrimaryKeyManyRelatedField(ManyRelatedField): + """ + Many-related field for PrimaryKeyRelatedField that resolves every pk with + a single `in_bulk()` query instead of one `get()` per item. + + Treated as private API — constructed via PrimaryKeyRelatedField.many_init. + """ + + def to_internal_value(self, data): + if isinstance(data, str) or not hasattr(data, '__iter__'): + self.fail('not_a_list', input_type=type(data).__name__) + if not self.allow_empty and len(data) == 0: + self.fail('empty') + + # Resolve every pk with a single query instead of one `get()` per item. + # Collect per-item errors (incorrect_type / does_not_exist / pk_field) + # keyed by index, matching ListField.run_child_validation. Input + # ordering and duplicates are preserved. + child = self.child_relation + queryset = child.get_queryset() + model_pk = queryset.model._meta.pk + # Each entry is (idx, lookup_key, value): `value` mirrors the per-item + # path (post-`pk_field`) and is used for error details, while + # `lookup_key` is the pk-typed value used to match `in_bulk()` results. + errors = {} + entries = [] + for idx, item in enumerate(data): + try: + value = item + if child.pk_field is not None: + value = child.pk_field.to_internal_value(value) + except ValidationError as exc: + errors[idx] = exc.detail + continue + try: + if isinstance(value, bool): + raise TypeError + # Coerce to the pk's Python type (e.g. "1" -> 1) so the lookup + # below matches the keys returned by `in_bulk()`, exactly as + # `queryset.get(pk=value)` would have. + lookup_key = model_pk.get_prep_value(value) + except (TypeError, ValueError): + try: + child.fail( + 'incorrect_type', data_type=type(value).__name__ + ) + except ValidationError as exc: + errors[idx] = exc.detail + continue + entries.append((idx, lookup_key, value)) + lookup_keys = [lookup_key for _, lookup_key, _ in entries] + try: + objects = queryset.in_bulk(lookup_keys) if lookup_keys else {} + except (TypeError, ValueError): + # queryset doesn't support in_bulk (e.g. distinct/sliced); fall + # back to a collecting per-item loop so mixed lists still report + # every invalid item. + errors = {} + result = [] + for idx, item in enumerate(data): + try: + result.append(child.to_internal_value(item)) + except ValidationError as exc: + errors[idx] = exc.detail + if errors: + raise ValidationError(errors) + return result + for idx, lookup_key, value in entries: + if lookup_key not in objects: + try: + child.fail('does_not_exist', pk_value=value) + except ValidationError as exc: + errors[idx] = exc.detail + if errors: + raise ValidationError(errors) + return [objects[lookup_key] for _, lookup_key, _ in entries] diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 4b867313cf..2e1d1d6d17 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -59,7 +59,8 @@ ) from rest_framework.relations import ( # NOQA # isort:skip HyperlinkedIdentityField, HyperlinkedRelatedField, ManyRelatedField, - PrimaryKeyRelatedField, RelatedField, SlugRelatedField, StringRelatedField, + PrimaryKeyManyRelatedField, PrimaryKeyRelatedField, RelatedField, + SlugRelatedField, StringRelatedField, ) # Non-field imports, but public API diff --git a/tests/test_relations_pk.py b/tests/test_relations_pk.py index 76dfe32367..222656dff6 100644 --- a/tests/test_relations_pk.py +++ b/tests/test_relations_pk.py @@ -301,21 +301,30 @@ def test_error_details_match_per_item_with_pk_field(self): queryset=ManyToManyTarget.objects.all(), pk_field=serializers.BooleanField()) child.bind('targets', serializers.Serializer()) + field = serializers.PrimaryKeyRelatedField( + queryset=ManyToManyTarget.objects.all(), many=True, + pk_field=serializers.BooleanField()) + field.bind('targets', serializers.Serializer()) with pytest.raises(serializers.ValidationError) as per_item: child.to_internal_value('true') with pytest.raises(serializers.ValidationError) as bulk: - child.to_internal_value_bulk(['true']) + field.to_internal_value(['true']) assert bulk.value.detail[0] == per_item.value.detail assert 'bool' in str(bulk.value.detail[0]) def test_many_related_field_with_non_related_child(self): - # ManyRelatedField may wrap a plain field that has no - # `to_internal_value_bulk`; it must fall back to per-item conversion. + # Plain ManyRelatedField (not the PK many subclass) still validates + # a non-related child with the per-item loop. field = serializers.ManyRelatedField( child_relation=serializers.IntegerField()) field.bind('values', serializers.Serializer()) assert field.to_internal_value([1, 2, 3]) == [1, 2, 3] + def test_many_true_uses_primary_key_many_related_field(self): + field = serializers.PrimaryKeyRelatedField( + queryset=ManyToManyTarget.objects.all(), many=True) + assert isinstance(field, serializers.PrimaryKeyManyRelatedField) + def test_collects_mixed_errors_in_one_query(self): field = self._field() missing = max(self.pks) + 1000 From 79ea3de0757149b27eb8561f8de3e499db830aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asif=20Saif=20Uddin=20=7B=22Auvi=22=3A=22=E0=A6=85?= =?UTF-8?q?=E0=A6=AD=E0=A6=BF=22=7D?= Date: Mon, 7 Sep 2026 12:56:29 +0600 Subject: [PATCH 7/7] Refactor many_init to handle non-PrimaryKeyRelatedField Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- rest_framework/relations.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rest_framework/relations.py b/rest_framework/relations.py index e4b6310e47..91ed97a062 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -249,6 +249,8 @@ def __init__(self, **kwargs): @classmethod def many_init(cls, *args, **kwargs): + if cls is not PrimaryKeyRelatedField: + return super().many_init(*args, **kwargs) # Use PrimaryKeyManyRelatedField so many=True validates with one # in_bulk() query. Slug/Hyperlinked keep RelatedField.many_init. list_kwargs = {'child_relation': cls(*args, **kwargs)}