Skip to content

Commit 65aa4b8

Browse files
Natghoauvipy
andauthored
Fix partial form data updates involving ListField (#9902)
* respecting ordered sequence while partial update is fixed * getting field order is re-ordered for backward compatibility * more detailed tests are added * .idea is added for Pycharm tmp files * reduntant check is removed, respecting o(n) search, checking prefixed keys is improved * missing tests are added, more detailed * Update tests/test_serializer_lists.py * comment is fixed for current solution explanation * standardized the errors * Update rest_framework/fields.py * listfield html input tests are seperated * unnecessary try/except is removed, explanation parts are refactored --------- Co-authored-by: Asif Saif Uddin {"Auvi":"অভি"} <auvipy@gmail.com>
1 parent 7e970cd commit 65aa4b8

File tree

4 files changed

+181
-4
lines changed

4 files changed

+181
-4
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,5 @@ coverage.*
1919
!.github
2020
!.gitignore
2121
!.pre-commit-config.yaml
22+
23+
.idea

rest_framework/fields.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1680,18 +1680,24 @@ def __init__(self, **kwargs):
16801680
self.validators.append(MinLengthValidator(self.min_length, message=message))
16811681

16821682
def get_value(self, dictionary):
1683-
if self.field_name not in dictionary:
1684-
if getattr(self.root, 'partial', False):
1685-
return empty
16861683
# We override the default field access in order to support
16871684
# lists in HTML forms.
16881685
if html.is_html_input(dictionary):
16891686
val = dictionary.getlist(self.field_name, [])
16901687
if len(val) > 0:
1691-
# Support QueryDict lists in HTML input.
1688+
# Support QueryDict lists and other list-like results in HTML input.
16921689
return val
1690+
# For partial updates, avoid calling parse_html_list unless indexed keys are present.
1691+
# This reduces unnecessary parsing overhead for omitted list fields.
1692+
if getattr(self.root, 'partial', False):
1693+
prefix = self.field_name + '['
1694+
if not any(key.startswith(prefix) for key in dictionary):
1695+
return empty
16931696
return html.parse_html_list(dictionary, prefix=self.field_name, default=empty)
16941697

1698+
# Non-HTML input: standard dictionary access
1699+
if self.field_name not in dictionary and getattr(self.root, 'partial', False):
1700+
return empty
16951701
return dictionary.get(self.field_name, empty)
16961702

16971703
def to_internal_value(self, data):

tests/test_fields.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,67 @@ class TestSerializer(serializers.Serializer):
576576
assert serializer.is_valid()
577577
assert serializer.validated_data == {'scores': ['']}
578578

579+
def test_partial_update_with_indexed_keys(self):
580+
"""
581+
Regression test for indexed HTML form keys with partial=True.
582+
When data is passed as `colors[0]=#ffffff&colors[1]=#000000`
583+
with partial=True, the field should parse indexed keys correctly.
584+
"""
585+
class TestSerializer(serializers.Serializer):
586+
colors = serializers.ListField(
587+
allow_null=True,
588+
child=serializers.CharField(max_length=7),
589+
required=False
590+
)
591+
name = serializers.CharField(max_length=100, required=False)
592+
593+
serializer = TestSerializer(
594+
data=QueryDict('colors[0]=#ffffff&colors[1]=#000000'),
595+
partial=True
596+
)
597+
assert serializer.is_valid()
598+
assert serializer.validated_data == {'colors': ['#ffffff', '#000000']}
599+
600+
def test_partial_update_omitted_list_field(self):
601+
"""
602+
When a ListField is omitted in a partial update (and there are no
603+
indexed keys for it), the field should be skipped and not included in
604+
the validated data.
605+
"""
606+
class TestSerializer(serializers.Serializer):
607+
colors = serializers.ListField(
608+
child=serializers.CharField(max_length=7),
609+
required=False
610+
)
611+
name = serializers.CharField(max_length=100)
612+
613+
# colors is omitted, only name is provided
614+
serializer = TestSerializer(
615+
data=QueryDict('name=Test'),
616+
partial=True
617+
)
618+
assert serializer.is_valid()
619+
assert serializer.validated_data == {'name': 'Test'}
620+
assert 'colors' not in serializer.validated_data
621+
622+
def test_partial_update_indexed_keys_ordering(self):
623+
"""
624+
Indexed keys should preserve the correct order even when
625+
they appear out of order in the QueryDict.
626+
"""
627+
class TestSerializer(serializers.Serializer):
628+
items = serializers.ListField(
629+
child=serializers.IntegerField(),
630+
required=False
631+
)
632+
633+
serializer = TestSerializer(
634+
data=QueryDict('items[2]=3&items[0]=1&items[1]=2'),
635+
partial=True
636+
)
637+
assert serializer.is_valid()
638+
assert serializer.validated_data == {'items': [1, 2, 3]}
639+
579640

580641
class TestCreateOnlyDefault:
581642
def setup_method(self):

tests/test_serializer_lists.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,51 @@ def test_validate_html_input(self):
286286
assert serializer.validated_data == expected_output
287287

288288

289+
class TestListFieldHTMLInput:
290+
"""
291+
Tests for ListField with HTML form input, including indexed keys.
292+
"""
293+
294+
def test_listfield_with_indexed_keys(self):
295+
"""
296+
Test that indexed keys (e.g., field[0], field[1]) work correctly
297+
in HTML form submissions.
298+
"""
299+
class CommunitySerializer(serializers.Serializer):
300+
colors = serializers.ListField(
301+
allow_null=True,
302+
child=serializers.CharField(label='Colors', max_length=7),
303+
required=False
304+
)
305+
# Simulate form data with indexed keys
306+
data = MultiValueDict({
307+
'colors[0]': ['#ffffff'],
308+
'colors[1]': ['#000000']
309+
})
310+
serializer = CommunitySerializer(data=data)
311+
assert serializer.is_valid()
312+
assert 'colors' in serializer.validated_data
313+
assert serializer.validated_data['colors'] == ['#ffffff', '#000000']
314+
315+
def test_listfield_standard_form_submission(self):
316+
"""
317+
Test standard HTML form list submission (e.g., multi-select).
318+
Ensures backward compatibility with existing behavior.
319+
"""
320+
class CommunitySerializer(serializers.Serializer):
321+
colors = serializers.ListField(
322+
child=serializers.CharField(label='Colors', max_length=7),
323+
required=True
324+
)
325+
# Standard multi-select form submission
326+
data = MultiValueDict({
327+
'colors': ['#ffffff', '#000000', '#ff0000']
328+
})
329+
serializer = CommunitySerializer(data=data)
330+
assert serializer.is_valid()
331+
assert serializer.validated_data['colors'] == ['#ffffff', '#000000', '#ff0000']
332+
333+
289334
class TestNestedListSerializerAllowEmpty:
290335
"""Tests the behavior of allow_empty=False when a ListSerializer is used as a field."""
291336

@@ -426,6 +471,69 @@ class MultipleChoiceSerializer(serializers.Serializer):
426471
assert serializer.validated_data == {}
427472
assert serializer.errors == {}
428473

474+
def test_partial_listfield_with_non_indexed_list(self):
475+
"""
476+
Test that ListField still works with non-indexed list submission
477+
in partial updates (backward compatibility check).
478+
"""
479+
class CommunitySerializer(serializers.Serializer):
480+
colors = serializers.ListField(
481+
allow_null=True,
482+
child=serializers.CharField(label='Colors', max_length=7),
483+
required=False
484+
)
485+
# Simulate standard HTML form list (e.g., multiple select)
486+
data = MultiValueDict({
487+
'colors': ['#ffffff', '#000000']
488+
})
489+
serializer = CommunitySerializer(data=data, partial=True)
490+
assert serializer.is_valid()
491+
assert 'colors' in serializer.validated_data
492+
assert serializer.validated_data['colors'] == ['#ffffff', '#000000']
493+
494+
def test_listfield_mixed_plain_and_indexed_keys(self):
495+
"""
496+
Test that when both plain field and indexed keys are present,
497+
the plain field takes precedence (standard HTML form behavior).
498+
"""
499+
class CommunitySerializer(serializers.Serializer):
500+
colors = serializers.ListField(
501+
allow_null=True,
502+
child=serializers.CharField(label='Colors', max_length=7),
503+
required=False
504+
)
505+
# When both present, getlist should win (standard HTML form behavior)
506+
data = MultiValueDict({
507+
'colors': ['#aaaaaa', '#bbbbbb'], # This should be used
508+
'colors[0]': ['#ffffff'], # These should be ignored
509+
'colors[1]': ['#000000']
510+
})
511+
serializer = CommunitySerializer(data=data, partial=True)
512+
assert serializer.is_valid()
513+
assert 'colors' in serializer.validated_data
514+
# Plain field values should take precedence
515+
assert serializer.validated_data['colors'] == ['#aaaaaa', '#bbbbbb']
516+
517+
def test_partial_listfield_no_data_returns_empty(self):
518+
"""
519+
Test that when a ListField is omitted in partial updates,
520+
it does not appear in validated_data (not even as an empty list).
521+
"""
522+
class CommunitySerializer(serializers.Serializer):
523+
name = serializers.CharField(max_length=100)
524+
colors = serializers.ListField(
525+
allow_null=True,
526+
child=serializers.CharField(label='Colors', max_length=7),
527+
required=False
528+
)
529+
data = MultiValueDict({
530+
'name': ['Community Name']
531+
})
532+
serializer = CommunitySerializer(data=data, partial=True)
533+
assert serializer.is_valid()
534+
assert 'name' in serializer.validated_data
535+
assert 'colors' not in serializer.validated_data # Should be skipped
536+
429537
def test_allow_empty_true(self):
430538
class ListSerializer(serializers.Serializer):
431539
update_field = serializers.IntegerField()

0 commit comments

Comments
 (0)