From a4504226fc46d30d7cf079c9507c7bc435669116 Mon Sep 17 00:00:00 2001 From: fbozhang <101728232+fbozhang@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:40:41 +0800 Subject: [PATCH] Preserve ordering in `MultipleChoiceField` (#9735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: MultipleChoiceField use ordered sort (cherry picked from commit 8436483e66af3d1317d99335b7fae95c1f58d13a) * test: fix unit tests (cherry picked from commit 6428ac4a05f4a33eb0813cc56d584f56e7bfac89) * test: test TestMultipleChoiceField can json serializable (cherry picked from commit 12908b149c446598682269f8df78290fa8268982) * test: fix unit test (cherry picked from commit 73a709c4b04ae510d61f2d426f93f6aef98b09fd) * minor: rest old formatting * fix: using pytest.fail to test * Update test_fields.py * Update test_fields.py * Update test_fields.py * test: add test cases * docs: update docs * Update docs/api-guide/fields.md * Skip inner list allocation * Fix punctuation --------- Co-authored-by: Asif Saif Uddin {"Auvi":"অভি"} Co-authored-by: Bruno Alla Co-authored-by: Bruno Alla --- docs/api-guide/fields.md | 2 +- rest_framework/fields.py | 23 +++++++++++++--------- tests/test_fields.py | 34 +++++++++++++++++++++++++++++---- tests/test_serializer_nested.py | 4 ++-- 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index ca6319288..4df98a213 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -417,7 +417,7 @@ Both the `allow_blank` and `allow_null` are valid options on `ChoiceField`, alth ## MultipleChoiceField -A field that can accept a set of zero, one or many values, chosen from a limited set of choices. Takes a single mandatory argument. `to_internal_value` returns a `set` containing the selected values. +A field that can accept a list of zero, one or many values, chosen from a limited set of choices. Takes a single mandatory argument. `to_internal_value` returns a `list` containing the selected values, deduplicated. **Signature:** `MultipleChoiceField(choices)` diff --git a/rest_framework/fields.py b/rest_framework/fields.py index f5009a730..8aced6a9c 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -1523,17 +1523,22 @@ class MultipleChoiceField(ChoiceField): if not self.allow_empty and len(data) == 0: self.fail('empty') - return { - # Arguments for super() are needed because of scoping inside - # comprehensions. - super(MultipleChoiceField, self).to_internal_value(item) - for item in data - } + # Arguments for super() are needed because of scoping inside + # comprehensions. + return list( + dict.fromkeys( + super(MultipleChoiceField, self).to_internal_value(item) + for item in data + ) + ) def to_representation(self, value): - return { - self.choice_strings_to_values.get(str(item), item) for item in value - } + return list( + dict.fromkeys( + self.choice_strings_to_values.get(str(item), item) + for item in value + ) + ) class FilePathField(ChoiceField): diff --git a/tests/test_fields.py b/tests/test_fields.py index e36079318..b8b1e4caf 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -24,6 +24,7 @@ from rest_framework.fields import ( BuiltinSignatureError, DjangoImageField, SkipField, empty, is_simple_callable ) +from rest_framework.utils import json from tests.models import UUIDForeignKeyTarget utc = datetime.timezone.utc @@ -2178,16 +2179,20 @@ class TestMultipleChoiceField(FieldValues): Valid and invalid values for `MultipleChoiceField`. """ valid_inputs = { - (): set(), - ('aircon',): {'aircon'}, - ('aircon', 'manual'): {'aircon', 'manual'}, + (): list(), + ('aircon',): ['aircon'], + ('aircon', 'aircon'): ['aircon'], + ('aircon', 'manual'): ['aircon', 'manual'], + ('manual', 'aircon'): ['manual', 'aircon'], } invalid_inputs = { 'abc': ['Expected a list of items but got type "str".'], ('aircon', 'incorrect'): ['"incorrect" is not a valid choice.'] } outputs = [ - (['aircon', 'manual', 'incorrect'], {'aircon', 'manual', 'incorrect'}) + (['aircon', 'manual', 'incorrect'], ['aircon', 'manual', 'incorrect']), + (['manual', 'aircon', 'incorrect'], ['manual', 'aircon', 'incorrect']), + (['aircon', 'manual', 'aircon'], ['aircon', 'manual']), ] field = serializers.MultipleChoiceField( choices=[ @@ -2204,6 +2209,27 @@ class TestMultipleChoiceField(FieldValues): field.partial = True assert field.get_value(QueryDict('')) == rest_framework.fields.empty + def test_valid_inputs_is_json_serializable(self): + for input_value, _ in get_items(self.valid_inputs): + validated = self.field.run_validation(input_value) + + try: + json.dumps(validated) + except TypeError as e: + pytest.fail(f'Validated output not JSON serializable: {repr(validated)}; Error: {e}') + + def test_output_is_json_serializable(self): + for output_value, _ in get_items(self.outputs): + representation = self.field.to_representation(output_value) + + try: + json.dumps(representation) + except TypeError as e: + pytest.fail( + f'to_representation output not JSON serializable: ' + f'{repr(representation)}; Error: {e}' + ) + class TestEmptyMultipleChoiceField(FieldValues): """ diff --git a/tests/test_serializer_nested.py b/tests/test_serializer_nested.py index 5e42645eb..4f1492af1 100644 --- a/tests/test_serializer_nested.py +++ b/tests/test_serializer_nested.py @@ -199,14 +199,14 @@ class TestNestedSerializerWithList: serializer = self.Serializer(data=input_data) assert serializer.is_valid() - assert serializer.validated_data['nested']['example'] == {1, 2} + assert serializer.validated_data['nested']['example'] == [1, 2] def test_nested_serializer_with_list_multipart(self): input_data = QueryDict('nested.example=1&nested.example=2') serializer = self.Serializer(data=input_data) assert serializer.is_valid() - assert serializer.validated_data['nested']['example'] == {1, 2} + assert serializer.validated_data['nested']['example'] == [1, 2] class TestNotRequiredNestedSerializerWithMany: