Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f0375ca
Fix #8926: ListSerializer preserves instance for many=True during val…
zainnadeem786 Jan 25, 2026
ac82e50
Update rest_framework/serializers.py
auvipy Feb 24, 2026
07de4b8
Merge branch 'main' into improve-many-true-validation-guidance
auvipy Feb 24, 2026
c402a57
Fix #8926 with minimal ListSerializer instance matching changes
zainnadeem786 Feb 24, 2026
66b8012
Keep virtualenv ignored in .gitignore
zainnadeem786 Feb 24, 2026
90e1a24
Fix Copilot/auvipy review: safe iterable check, restore save() assert…
zainnadeem786 Feb 24, 2026
0acf49a
Refine ListSerializer review follow-ups and cleanup
zainnadeem786 Feb 24, 2026
1484520
Restore serializers docstrings from upstream main
zainnadeem786 Feb 25, 2026
ef7e976
Update rest_framework/serializers.py
auvipy Feb 25, 2026
c665595
Merge branch 'main' into improve-many-true-validation-guidance
zainnadeem786 Feb 25, 2026
b61b472
Remove unreachable return in ListSerializer.to_internal_value
zainnadeem786 Feb 25, 2026
22caa96
Update rest_framework/serializers.py
auvipy Feb 25, 2026
de40cb5
Merge branch 'main' into improve-many-true-validation-guidance
browniebroke Feb 26, 2026
5176e44
Merge branch 'main' into improve-many-true-validation-guidance
auvipy Mar 2, 2026
c9665dd
Address review follow-ups in ListSerializer internals
zainnadeem786 Mar 2, 2026
83e9965
Merge branch 'main' into improve-many-true-validation-guidance
zainnadeem786 Mar 14, 2026
21417c8
Merge branch 'main' into improve-many-true-validation-guidance
zainnadeem786 Mar 17, 2026
781cf7e
Merge branch 'main' into improve-many-true-validation-guidance
zainnadeem786 Mar 27, 2026
5bdf57e
Merge branch 'main' into improve-many-true-validation-guidance
auvipy Mar 31, 2026
f08921e
Fix ListSerializer run_child_validation and save() issues
zainnadeem786 Mar 31, 2026
bb6b3bb
Merge branch 'improve-many-true-validation-guidance' of https://githu…
zainnadeem786 Mar 31, 2026
dcb4ad1
Merge branch 'main' into improve-many-true-validation-guidance
auvipy Apr 5, 2026
2fa19ed
Refine ListSerializer instance matching: reduce scope, add fallback b…
zainnadeem786 Apr 7, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
/env/
MANIFEST
coverage.*
venv/
.coverage
.cache/

Expand Down
111 changes: 92 additions & 19 deletions rest_framework/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,35 @@ def run_child_validation(self, data):
self.child.initial_data = data
return super().run_child_validation(data)
"""
return self.child.run_validation(data)
if not hasattr(self.child, 'instance'):
return self.child.run_validation(data)

if not (
hasattr(self, '_list_serializer_instance_map') and
isinstance(data, Mapping)
):
return self.child.run_validation(data)

lookup_field = getattr(getattr(self.child, 'Meta', None), 'lookup_field', None)
data_pk = data.get(lookup_field)
if data_pk is None:
data_pk = data.get('id')
if data_pk is None:
data_pk = data.get('pk')

if data_pk is None:
return self.child.run_validation(data)

child_instance = self._list_serializer_instance_map.get(str(data_pk))
if child_instance is None:
return self.child.run_validation(data)

original_instance = self.child.instance
try:
self.child.instance = child_instance
return self.child.run_validation(data)
finally:
self.child.instance = original_instance

def to_internal_value(self, data):
"""
Expand All @@ -674,12 +702,16 @@ def to_internal_value(self, data):
data = html.parse_html_list(data, default=[])

if not isinstance(data, list):
message = self.error_messages['not_a_list'].format(
input_type=type(data).__name__
)
raise ValidationError({
api_settings.NON_FIELD_ERRORS_KEY: [message]
}, code='not_a_list')
api_settings.NON_FIELD_ERRORS_KEY: [
ErrorDetail(
self.error_messages['not_a_list'].format(
input_type=type(data).__name__
),
code='not_a_list'
)
]
})
Comment on lines -677 to +714
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why this was changed? It looks a bit out of scope but I'm probably missing something...


if not self.allow_empty and len(data) == 0:
message = self.error_messages['empty']
Expand All @@ -702,19 +734,49 @@ def to_internal_value(self, data):
ret = []
errors = []

for item in data:
try:
validated = self.run_child_validation(item)
except ValidationError as exc:
errors.append(exc.detail)
else:
ret.append(validated)
errors.append({})
# Build a primary key lookup for instance matching in many=True updates.
instance_map = None
if self.instance is not None:
if isinstance(self.instance, Mapping):
instance_map = {str(k): v for k, v in self.instance.items()}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line isn't covered by tests. Would be nice to come up with a test case covering it...

Also, what happens if self.instance is non of a Mapping, list, tuple or QuerySet? Should we have an else branch falling back to the previous behaviour? Should we raise an error if it should never happen? Please add a test to cover this case too

elif isinstance(self.instance, (list, tuple, models.query.QuerySet)):
instance_map = {}
lookup_field = getattr(getattr(self.child, 'Meta', None), 'lookup_field', None)

for obj in self.instance:
if lookup_field is not None:
pk = getattr(obj, lookup_field, None)
else:
pk = getattr(obj, 'pk', None)
if pk is None:
pk = getattr(obj, 'id', None)

if any(errors):
raise ValidationError(errors)
if pk is not None:
key = str(pk)
# If duplicate keys are present, keep the last value,
# matching standard mapping assignment behavior.
instance_map[key] = obj

return ret
if instance_map is not None:
self._list_serializer_instance_map = instance_map

try:
for item in data:
try:
validated = self.run_child_validation(item)
except ValidationError as exc:
errors.append(exc.detail)
else:
ret.append(validated)
errors.append({})

if any(errors):
raise ValidationError(errors)

return ret
finally:
if hasattr(self, '_list_serializer_instance_map'):
delattr(self, '_list_serializer_instance_map')

def to_representation(self, data):
"""
Expand Down Expand Up @@ -749,16 +811,27 @@ def save(self, **kwargs):
"""
Save and return a list of object instances.
"""
assert hasattr(self, '_errors'), (
'You must call `.is_valid()` before calling `.save()`.'
)
assert not self.errors, (
'You cannot call `.save()` on a serializer with invalid data.'
)
Comment on lines +814 to +819
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems unrelated and lacks test coverage


# Guard against incorrect use of `serializer.save(commit=False)`
assert 'commit' not in kwargs, (
"'commit' is not a valid keyword argument to the 'save()' method. "
"If you need to access data before committing to the database then "
"inspect 'serializer.validated_data' instead. "
"You can also pass additional keyword arguments to 'save()' if you "
"need to set extra attributes on the saved model instance. "
"For example: 'serializer.save(owner=request.user)'.'"
"For example: 'serializer.save(owner=request.user)'."
)
assert not hasattr(self, '_data'), (
"You cannot call `.save()` after accessing `serializer.data`."
"If you need to access data before committing to the database then "
"inspect 'serializer.validated_data' instead. "
)
Comment on lines +830 to 834
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here


validated_data = [
{**attrs, **kwargs} for attrs in self.validated_data
]
Expand Down
178 changes: 178 additions & 0 deletions tests/test_serializer_lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,155 @@ def update(self, instance, validated_data):
assert updated_instances == expected_output


class TestListSerializerInstanceMatching:
def test_matching_with_id(self):
seen_instances = []

class TestSerializer(serializers.Serializer):
id = serializers.IntegerField()

def validate(self, attrs):
seen_instances.append(self.instance)
return attrs

instance = [
BasicObject(id=1),
BasicObject(id=2),
]
input_data = [
{'id': 1},
{'id': 2},
]

serializer = TestSerializer(instance, data=input_data, many=True)
assert serializer.is_valid()
assert seen_instances == instance

def test_matching_with_pk(self):
seen_instances = []

class TestSerializer(serializers.Serializer):
pk = serializers.IntegerField()

def validate(self, attrs):
seen_instances.append(self.instance)
return attrs

instance = [
BasicObject(pk=1),
BasicObject(pk=2),
]
input_data = [
{'pk': 1},
{'pk': 2},
]

serializer = TestSerializer(instance, data=input_data, many=True)
assert serializer.is_valid()
assert seen_instances == instance

def test_matching_with_id_against_object_with_pk_only(self):
seen_instances = []

class TestSerializer(serializers.Serializer):
id = serializers.IntegerField()

def validate(self, attrs):
seen_instances.append(self.instance)
return attrs

instance = [BasicObject(pk=1)]
input_data = [{'id': 1}]

serializer = TestSerializer(instance, data=input_data, many=True)
assert serializer.is_valid()
assert seen_instances == instance

def test_mapping_instance_matching(self):
seen_instances = []

class TestSerializer(serializers.Serializer):
id = serializers.IntegerField()

def validate(self, attrs):
seen_instances.append(self.instance)
return attrs

obj1 = BasicObject(id=1)
obj2 = BasicObject(id=2)
instance = {
'1': obj1,
'2': obj2,
}
input_data = [
{'id': 1},
{'id': 2},
]

serializer = TestSerializer(instance, data=input_data, many=True)
assert serializer.is_valid()
assert seen_instances == [obj1, obj2]

def test_unsupported_instance_type_preserves_original_behavior(self):
seen_instances = []

class TestSerializer(serializers.Serializer):
id = serializers.IntegerField()

def validate(self, attrs):
seen_instances.append(self.instance)
return attrs

serializer = TestSerializer(instance=123, data=[{'id': 1}], many=True)
assert serializer.is_valid()
assert seen_instances == [123]

def test_missing_lookup_field_in_data_does_not_assign_instance(self):
seen_instances = []

class TestSerializer(serializers.Serializer):
id = serializers.IntegerField(required=False)

class Meta:
lookup_field = 'uuid'

def validate(self, attrs):
seen_instances.append(self.instance)
return attrs

class TestListSerializer(serializers.ListSerializer):
child = TestSerializer()

serializer = TestListSerializer(
instance=[BasicObject(id=1, uuid='uuid-1')],
data=[{'id': 1}],
)
assert serializer.is_valid()
assert seen_instances == [None]

def test_matching_with_configurable_lookup_field(self):
seen_instances = []

class TestSerializer(serializers.Serializer):
id = serializers.IntegerField(required=False)
uuid = serializers.CharField()

class Meta:
lookup_field = 'uuid'

def validate(self, attrs):
seen_instances.append(self.instance)
return attrs

obj1 = BasicObject(id=1, uuid='uuid-1')
obj2 = BasicObject(id=2, uuid='uuid-2')
input_data = [{'id': 1, 'uuid': 'uuid-2'}]

serializer = TestSerializer([obj1, obj2], data=input_data, many=True)
assert serializer.is_valid()
assert seen_instances == [obj2]


class TestNestedListSerializer:
"""
Tests for using a ListSerializer as a field.
Expand Down Expand Up @@ -883,3 +1032,32 @@ def test(self):
queryset = NullableOneToOneSource.objects.all()
serializer = self.serializer(queryset, many=True)
assert serializer.data


def test_many_true_instance_level_validation_uses_matched_instance():
class Obj:
def __init__(self, id, valid):
self.id = id
self.valid = valid

class TestSerializer(serializers.Serializer):
id = serializers.IntegerField()
status = serializers.CharField()

def validate_status(self, value):
if self.instance is None:
raise serializers.ValidationError("Instance not matched")
if not self.instance.valid:
raise serializers.ValidationError("Invalid instance")
return value

objs = [Obj(1, True), Obj(2, False)]
serializer = TestSerializer(
instance=objs,
data=[{"id": 1, "status": "ok"}, {"id": 2, "status": "fail"}],
many=True,
partial=True,
)

assert not serializer.is_valid()
assert serializer.errors == [{}, {'status': ['Invalid instance']}]
Loading