Skip to content

Commit 69d1cd7

Browse files
veeceeyauvipybrowniebroke
authored
Fix DictField HTML input returning empty dict for missing fields (#9891)
* Fix DictField returning empty dict instead of empty for missing HTML input (#6234) When using DictField with HTML form (multipart/form-data) input, parse_html_dict always returned an empty MultiValueDict when no matching keys were found. This made it impossible to distinguish between an unspecified field and an empty input, causing issues with required/default field handling. This aligns parse_html_dict with parse_html_list by adding a default parameter that is returned when no matching keys are found. * Address review feedback: add regression test, improve assertions - Add regression test for nested serializer with QueryDict to cover the serializers.py get_value change (test_nested_serializer_not_required_with_querydict) - Use `assert serializer.is_valid(), serializer.errors` for better test output on failures - Keep `default=data` in to_internal_value as `default={}` would discard already-parsed MultiValueDict keys from get_value * Use empty dict as default in DictField.to_internal_value() Change default from data to {} in parse_html_dict call within DictField.to_internal_value(), falling back to a dict conversion of the MultiValueDict when no dot-separated keys are found. This is cleaner than using the input data as its own default. * Add support for clearing a dict field with form data * Update rest_framework/fields.py * Update rest_framework/utils/html.py --------- Co-authored-by: Asif Saif Uddin {"Auvi":"অভি"} <auvipy@gmail.com> Co-authored-by: Bruno Alla <browniebroke@users.noreply.github.com>
1 parent bbc62ac commit 69d1cd7

5 files changed

Lines changed: 116 additions & 5 deletions

File tree

rest_framework/fields.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1750,15 +1750,28 @@ def get_value(self, dictionary):
17501750
# We override the default field access in order to support
17511751
# dictionaries in HTML forms.
17521752
if html.is_html_input(dictionary):
1753-
return html.parse_html_dict(dictionary, prefix=self.field_name)
1753+
result = html.parse_html_dict(dictionary, prefix=self.field_name, default=empty)
1754+
if result is not empty:
1755+
return result
1756+
# If the field name itself is present in the input,
1757+
# treat it as an explicit empty dict (e.g. clearing the field).
1758+
if self.field_name in dictionary:
1759+
return {}
1760+
return empty
17541761
return dictionary.get(self.field_name, empty)
17551762

17561763
def to_internal_value(self, data):
17571764
"""
17581765
Dicts of native values <- Dicts of primitive datatypes.
17591766
"""
17601767
if html.is_html_input(data):
1761-
data = html.parse_html_dict(data)
1768+
# Coerce HTML form inputs (e.g. QueryDict/MultiValueDict) to a plain dict.
1769+
# Use `.dict()` when available to preserve existing behavior of taking
1770+
# the first value for each key, otherwise fall back to `dict()`.
1771+
if hasattr(data, 'dict'):
1772+
data = data.dict()
1773+
else:
1774+
data = dict(data)
17621775
if not isinstance(data, dict):
17631776
self.fail('not_a_dict', input_type=type(data).__name__)
17641777
if not self.allow_empty and len(data) == 0:

rest_framework/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ def get_value(self, dictionary):
438438
# We override the default field access in order to support
439439
# nested HTML forms.
440440
if html.is_html_input(dictionary):
441-
return html.parse_html_dict(dictionary, prefix=self.field_name) or empty
441+
return html.parse_html_dict(dictionary, prefix=self.field_name, default=empty)
442442
return dictionary.get(self.field_name, empty)
443443

444444
def run_validation(self, data=empty):

rest_framework/utils/html.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,10 @@ def parse_html_list(dictionary, prefix='', default=None):
6666
return [ret[item] for item in sorted(ret)] if ret else default
6767

6868

69-
def parse_html_dict(dictionary, prefix=''):
69+
NOT_PROVIDED = object()
70+
71+
72+
def parse_html_dict(dictionary, prefix='', default=NOT_PROVIDED):
7073
"""
7174
Used to support dictionary values in HTML forms.
7275
@@ -81,6 +84,9 @@ def parse_html_dict(dictionary, prefix=''):
8184
'email': 'example@example.com'
8285
}
8386
}
87+
88+
:returns a MultiValueDict of the parsed data, or the value specified in
89+
``default`` if the dict field was not present in the input
8490
"""
8591
ret = MultiValueDict()
8692
regex = re.compile(r'^%s\.(.+)$' % re.escape(prefix))
@@ -92,4 +98,8 @@ def parse_html_dict(dictionary, prefix=''):
9298
value = dictionary.getlist(field)
9399
ret.setlist(key, value)
94100

95-
return ret
101+
# Left for backwards compatibility in case of external caller
102+
if default is NOT_PROVIDED:
103+
return ret
104+
105+
return ret if ret else default

tests/test_fields.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2558,6 +2558,74 @@ def test_allow_empty_disallowed(self):
25582558

25592559
assert exc_info.value.detail == ['This dictionary may not be empty.']
25602560

2561+
def test_query_dict_input_with_dot_separated_keys(self):
2562+
"""
2563+
DictField should correctly parse HTML form (QueryDict) input
2564+
with dot-separated keys.
2565+
"""
2566+
class TestSerializer(serializers.Serializer):
2567+
data = serializers.DictField(child=serializers.CharField())
2568+
2569+
serializer = TestSerializer(data=QueryDict('data.a=1&data.b=2'))
2570+
assert serializer.is_valid(), serializer.errors
2571+
assert serializer.validated_data == {'data': {'a': '1', 'b': '2'}}
2572+
2573+
def test_query_dict_input_no_values_uses_default(self):
2574+
"""
2575+
When no matching keys are present in the QueryDict and a default
2576+
is set, the field should return the default value.
2577+
"""
2578+
class TestSerializer(serializers.Serializer):
2579+
a = serializers.IntegerField(required=True)
2580+
data = serializers.DictField(default=lambda: {'x': 'y'})
2581+
2582+
serializer = TestSerializer(data=QueryDict('a=1'))
2583+
assert serializer.is_valid(), serializer.errors
2584+
assert serializer.validated_data == {'a': 1, 'data': {'x': 'y'}}
2585+
2586+
def test_query_dict_input_no_values_no_default_and_not_required(self):
2587+
"""
2588+
When no matching keys are present in the QueryDict, there is no
2589+
default, and the field is not required, the field should be
2590+
skipped entirely from validated_data.
2591+
"""
2592+
class TestSerializer(serializers.Serializer):
2593+
data = serializers.DictField(required=False)
2594+
2595+
serializer = TestSerializer(data=QueryDict(''))
2596+
assert serializer.is_valid(), serializer.errors
2597+
assert serializer.validated_data == {}
2598+
2599+
def test_query_dict_input_no_values_required(self):
2600+
"""
2601+
When no matching keys are present in the QueryDict and the field
2602+
is required, validation should fail.
2603+
"""
2604+
class TestSerializer(serializers.Serializer):
2605+
data = serializers.DictField(required=True)
2606+
2607+
serializer = TestSerializer(data=QueryDict(''))
2608+
assert not serializer.is_valid()
2609+
assert 'data' in serializer.errors
2610+
2611+
def test_partial_update_can_clear_html_dict_field(self):
2612+
"""
2613+
Test that a partial update can clear a DictField when provided with an
2614+
empty string value through a QueryDict.
2615+
"""
2616+
class TestSerializer(serializers.Serializer):
2617+
field_name = serializers.DictField(required=False)
2618+
other_field = serializers.CharField(required=False)
2619+
2620+
serializer = TestSerializer(
2621+
data=QueryDict('field_name='),
2622+
partial=True,
2623+
)
2624+
assert serializer.is_valid()
2625+
assert 'field_name' in serializer.validated_data
2626+
assert serializer.validated_data['field_name'] == {}
2627+
assert 'other_field' not in serializer.validated_data
2628+
25612629

25622630
class TestNestedDictField(FieldValues):
25632631
"""

tests/test_serializer.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,26 @@ class Serializer(serializers.Serializer):
552552
assert Serializer({'nested': {'a': '3', 'b': {}}}).data == {'nested': {'a': '3', 'c': '2'}}
553553
assert Serializer({'nested': {'a': '3', 'b': {'c': '4'}}}).data == {'nested': {'a': '3', 'c': '4'}}
554554

555+
def test_nested_serializer_not_required_with_querydict(self):
556+
"""
557+
When a nested serializer is not required and the QueryDict does
558+
not contain any matching prefixed keys, the nested serializer
559+
should be omitted from validated_data. Regression test for #6234.
560+
"""
561+
from django.http import QueryDict
562+
563+
class NestedSerializer(serializers.Serializer):
564+
x = serializers.CharField()
565+
566+
class ParentSerializer(serializers.Serializer):
567+
name = serializers.CharField()
568+
nested = NestedSerializer(required=False)
569+
570+
serializer = ParentSerializer(data=QueryDict("name=test"))
571+
assert serializer.is_valid(), serializer.errors
572+
assert serializer.validated_data == {"name": "test"}
573+
assert "nested" not in serializer.validated_data
574+
555575
def test_default_for_allow_null(self):
556576
"""
557577
Without an explicit default, allow_null implies default=None when serializing. #5518 #5708

0 commit comments

Comments
 (0)