From 3fc1c73168b6450ae7f299fd6ef328abab2674d0 Mon Sep 17 00:00:00 2001 From: Shrikant Giri Date: Fri, 19 Dec 2025 22:12:19 +0530 Subject: [PATCH 1/3] Prevent NestedBoundField child access crash after parent validation error (#4073) --- rest_framework/utils/serializer_helpers.py | 10 +++++- tests/test_bound_fields.py | 38 +++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/rest_framework/utils/serializer_helpers.py b/rest_framework/utils/serializer_helpers.py index e6bd84f30..1326d879b 100644 --- a/rest_framework/utils/serializer_helpers.py +++ b/rest_framework/utils/serializer_helpers.py @@ -130,13 +130,21 @@ class NestedBoundField(BoundField): def __getitem__(self, key): field = self.fields[key] value = self.value.get(key) if self.value else None - error = self.errors.get(key) if isinstance(self.errors, dict) else None + + if isinstance(self.errors, dict): + error = self.errors.get(key) + elif isinstance(self.errors, list): + error = {} # normalize list to empty dict for nested children + else: + error = None + if hasattr(field, 'fields'): return NestedBoundField(field, value, error, prefix=self.name + '.') elif getattr(field, '_is_jsonfield', False): return JSONBoundField(field, value, error, prefix=self.name + '.') return BoundField(field, value, error, prefix=self.name + '.') + def as_form_field(self): values = {} for key, value in self.value.items(): diff --git a/tests/test_bound_fields.py b/tests/test_bound_fields.py index eee7d9b85..9cb9dbe79 100644 --- a/tests/test_bound_fields.py +++ b/tests/test_bound_fields.py @@ -1,7 +1,7 @@ from django.http import QueryDict from rest_framework import serializers - +from rest_framework.exceptions import ValidationError class TestSimpleBoundField: def test_empty_bound_field(self): @@ -211,6 +211,42 @@ class TestNestedBoundField: rendered_packed = ''.join(rendered.split()) assert rendered_packed == expected_packed + def test_child_bound_field_after_parent_validation_error(self): + """ + After a parent-level ValidationError on a nested serializer field, + child BoundFields should remain accessible and receive a mapping + for `errors` so the Browsable API can render safely. + + Regression test for #4073. + """ + class ChildSerializer(serializers.Serializer): + value = serializers.CharField() + class ParentSerializer(serializers.Serializer): + nested = ChildSerializer() + + def validate_nested(self, nested): + # Raise parent-level (non-field) validation error + raise ValidationError(["parent-level nested error"]) + + serializer = ParentSerializer(data={"nested": {"value": "ignored"}}) + assert not serializer.is_valid() + + # Parent-level error is a list (current problematic case) + assert serializer.errors["nested"] == ["parent-level nested error"] + + # Access nested bound field + parent_bound = serializer["nested"] + + # Access child bound field – should not raise + child_bound = parent_bound["value"] + + # Core contract: errors must be a mapping, not None or list + assert isinstance(child_bound.errors, dict) + + # Sanity checks + assert child_bound.value == "ignored" + assert child_bound.name == "nested.value" + class TestJSONBoundField: def test_as_form_fields(self): From e9aaf0649b4942c05ac7f73f44f99bf74791c794 Mon Sep 17 00:00:00 2001 From: Shrikant Giri Date: Fri, 19 Dec 2025 22:33:43 +0530 Subject: [PATCH 2/3] chore: apply pre-commit formatting --- rest_framework/utils/serializer_helpers.py | 1 - tests/test_bound_fields.py | 17 ++--------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/rest_framework/utils/serializer_helpers.py b/rest_framework/utils/serializer_helpers.py index 1326d879b..94ac79442 100644 --- a/rest_framework/utils/serializer_helpers.py +++ b/rest_framework/utils/serializer_helpers.py @@ -144,7 +144,6 @@ class NestedBoundField(BoundField): return JSONBoundField(field, value, error, prefix=self.name + '.') return BoundField(field, value, error, prefix=self.name + '.') - def as_form_field(self): values = {} for key, value in self.value.items(): diff --git a/tests/test_bound_fields.py b/tests/test_bound_fields.py index 9cb9dbe79..981c66b37 100644 --- a/tests/test_bound_fields.py +++ b/tests/test_bound_fields.py @@ -3,6 +3,7 @@ from django.http import QueryDict from rest_framework import serializers from rest_framework.exceptions import ValidationError + class TestSimpleBoundField: def test_empty_bound_field(self): class ExampleSerializer(serializers.Serializer): @@ -212,15 +213,9 @@ class TestNestedBoundField: assert rendered_packed == expected_packed def test_child_bound_field_after_parent_validation_error(self): - """ - After a parent-level ValidationError on a nested serializer field, - child BoundFields should remain accessible and receive a mapping - for `errors` so the Browsable API can render safely. - - Regression test for #4073. - """ class ChildSerializer(serializers.Serializer): value = serializers.CharField() + class ParentSerializer(serializers.Serializer): nested = ChildSerializer() @@ -233,17 +228,9 @@ class TestNestedBoundField: # Parent-level error is a list (current problematic case) assert serializer.errors["nested"] == ["parent-level nested error"] - - # Access nested bound field parent_bound = serializer["nested"] - - # Access child bound field – should not raise child_bound = parent_bound["value"] - - # Core contract: errors must be a mapping, not None or list assert isinstance(child_bound.errors, dict) - - # Sanity checks assert child_bound.value == "ignored" assert child_bound.name == "nested.value" From 30d7677337ec32926104cd22bb340948728c6836 Mon Sep 17 00:00:00 2001 From: Shrikant Giri Date: Sat, 20 Dec 2025 10:45:08 +0530 Subject: [PATCH 3/3] tests: assert empty child errors after parent validation failure --- tests/test_bound_fields.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_bound_fields.py b/tests/test_bound_fields.py index 981c66b37..7e76113d1 100644 --- a/tests/test_bound_fields.py +++ b/tests/test_bound_fields.py @@ -231,6 +231,7 @@ class TestNestedBoundField: parent_bound = serializer["nested"] child_bound = parent_bound["value"] assert isinstance(child_bound.errors, dict) + assert child_bound.errors == {} assert child_bound.value == "ignored" assert child_bound.name == "nested.value"