Skip to content

Commit ca16945

Browse files
authored
Merge branch 'main' into fix/issue-6234-dictfield-html-parsing
2 parents e9ebed5 + 385df20 commit ca16945

9 files changed

Lines changed: 350 additions & 15 deletions

File tree

.github/workflows/main.yml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ jobs:
2828
name: Python ${{ matrix.python-version }}
2929
runs-on: ubuntu-24.04
3030

31+
services:
32+
postgres:
33+
image: postgres:16
34+
env:
35+
POSTGRES_DB: postgres
36+
POSTGRES_USER: postgres
37+
POSTGRES_PASSWORD: postgres
38+
ports:
39+
- 5432:5432
40+
options: >-
41+
--health-cmd pg_isready
42+
--health-interval 10s
43+
--health-timeout 5s
44+
--health-retries 5
45+
3146
strategy:
3247
matrix:
3348
python-version:
@@ -37,6 +52,9 @@ jobs:
3752
- '3.13'
3853
- '3.14'
3954

55+
env:
56+
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
57+
4058
steps:
4159
- uses: actions/checkout@v6
4260

@@ -52,6 +70,9 @@ jobs:
5270
- name: Install dependencies
5371
run: python -m pip install --upgrade tox
5472

73+
- name: Create unaccent extension
74+
run: PGPASSWORD=postgres psql -h localhost -U postgres -d postgres -c 'CREATE EXTENSION IF NOT EXISTS unaccent;'
75+
5576
- name: Run tox targets for ${{ matrix.python-version }}
5677
run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d . | cut -f 1 -d '-')
5778

@@ -61,7 +82,7 @@ jobs:
6182
tox -e base,dist,docs
6283
6384
- name: Upload coverage
64-
uses: codecov/codecov-action@v5
85+
uses: codecov/codecov-action@v6
6586
with:
6687
env_vars: TOXENV,DJANGO
6788

.github/workflows/release.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ jobs:
5858
name: python-package-distributions
5959
path: dist/
6060
- name: Publish distribution 📦 to TestPyPI
61-
uses: pypa/gh-action-pypi-publish@release/v1.13
61+
uses: pypa/gh-action-pypi-publish@release/v1.14
6262
with:
6363
repository-url: https://test.pypi.org/legacy/
6464
skip-existing: true
@@ -81,7 +81,7 @@ jobs:
8181
name: python-package-distributions
8282
path: dist/
8383
- name: Publish distribution 📦 to PyPI
84-
uses: pypa/gh-action-pypi-publish@release/v1.13
84+
uses: pypa/gh-action-pypi-publish@release/v1.14
8585

8686
github-release:
8787
name: >-
@@ -100,7 +100,7 @@ jobs:
100100
name: python-package-distributions
101101
path: dist/
102102
- name: Sign the dists with Sigstore
103-
uses: sigstore/gh-action-sigstore-python@v3.2.0
103+
uses: sigstore/gh-action-sigstore-python@v3.3.0
104104
with:
105105
inputs: >-
106106
./dist/*.tar.gz

.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

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dev = [
4444
{ include-group = "test" },
4545
]
4646
test = [
47+
"dj-database-url>=3.1.0",
4748
"importlib-metadata<10.0",
4849
# Pytest for running the tests.
4950
"pytest==9.*",
@@ -68,7 +69,7 @@ optional = [
6869
"legacy-cgi; python_version>='3.13'",
6970
"markdown>=3.3.7",
7071
"psycopg[binary]>=3.1.8",
71-
"pygments>=2.17,<2.20",
72+
"pygments>=2.17,<2.21",
7273
"pyyaml>=5.3.1,<6.1",
7374
"requests",
7475
"uritemplate",
@@ -116,6 +117,9 @@ keep_full_version = true
116117
[tool.pytest.ini_options]
117118
addopts = "--tb=short --strict-markers -ra"
118119
testpaths = [ "tests" ]
120+
markers = [
121+
"requires_postgres: marks tests as requiring a PostgreSQL database backend",
122+
]
119123
filterwarnings = [
120124
"ignore:'cgi' is deprecated:DeprecationWarning",
121125
]

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/conftest.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import os
22

3+
import dj_database_url
34
import django
5+
import pytest
46
from django.core import management
57

68

@@ -13,19 +15,27 @@ def pytest_addoption(parser):
1315
def pytest_configure(config):
1416
from django.conf import settings
1517

16-
settings.configure(
17-
DEBUG_PROPAGATE_EXCEPTIONS=True,
18-
DEFAULT_AUTO_FIELD="django.db.models.AutoField",
19-
DATABASES={
18+
if os.getenv('DATABASE_URL'):
19+
databases = {
20+
'default': dj_database_url.config(),
21+
'secondary': dj_database_url.config(),
22+
}
23+
else:
24+
databases = {
2025
'default': {
2126
'ENGINE': 'django.db.backends.sqlite3',
2227
'NAME': ':memory:'
2328
},
2429
'secondary': {
2530
'ENGINE': 'django.db.backends.sqlite3',
2631
'NAME': ':memory:'
27-
}
28-
},
32+
},
33+
}
34+
35+
settings.configure(
36+
DEBUG_PROPAGATE_EXCEPTIONS=True,
37+
DEFAULT_AUTO_FIELD="django.db.models.AutoField",
38+
DATABASES=databases,
2939
SITE_ID=1,
3040
SECRET_KEY='not very secret in tests',
3141
USE_I18N=True,
@@ -65,6 +75,12 @@ def pytest_configure(config):
6575
),
6676
)
6777

78+
# Add django.contrib.postgres when using a PostgreSQL database
79+
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql':
80+
settings.INSTALLED_APPS += (
81+
'django.contrib.postgres',
82+
)
83+
6884
# guardian is optional
6985
try:
7086
import guardian # NOQA
@@ -91,3 +107,13 @@ def pytest_configure(config):
91107

92108
if config.getoption('--staticfiles'):
93109
management.call_command('collectstatic', verbosity=0, interactive=False)
110+
111+
112+
def pytest_collection_modifyitems(config, items):
113+
from django.conf import settings
114+
115+
if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.postgresql':
116+
skip_postgres = pytest.mark.skip(reason='Requires PostgreSQL database backend')
117+
for item in items:
118+
if 'requires_postgres' in item.keywords:
119+
item.add_marker(skip_postgres)

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_filters.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,109 @@ class SearchListView(generics.ListAPIView):
291291
]
292292

293293

294+
@pytest.mark.requires_postgres
295+
class SearchFilterFullTextTests(TestCase):
296+
@classmethod
297+
def setUpTestData(cls):
298+
SearchFilterModel.objects.create(title='The quick brown fox', text='jumps over the lazy dog')
299+
SearchFilterModel.objects.create(title='The slow brown turtle', text='crawls under the fence')
300+
SearchFilterModel.objects.create(title='A bright sunny day', text='in the park with friends')
301+
302+
def test_full_text_search_single_term(self):
303+
class SearchListView(generics.ListAPIView):
304+
queryset = SearchFilterModel.objects.all()
305+
serializer_class = SearchFilterSerializer
306+
filter_backends = (filters.SearchFilter,)
307+
search_fields = ('@title',)
308+
309+
view = SearchListView.as_view()
310+
request = factory.get('/', {'search': 'fox'})
311+
response = view(request)
312+
assert len(response.data) == 1
313+
assert response.data[0]['title'] == 'The quick brown fox'
314+
315+
def test_full_text_search_multiple_results(self):
316+
class SearchListView(generics.ListAPIView):
317+
queryset = SearchFilterModel.objects.all()
318+
serializer_class = SearchFilterSerializer
319+
filter_backends = (filters.SearchFilter,)
320+
search_fields = ('@title',)
321+
322+
view = SearchListView.as_view()
323+
request = factory.get('/', {'search': 'brown'})
324+
response = view(request)
325+
assert len(response.data) == 2
326+
titles = {item['title'] for item in response.data}
327+
assert titles == {'The quick brown fox', 'The slow brown turtle'}
328+
329+
def test_full_text_search_no_results(self):
330+
class SearchListView(generics.ListAPIView):
331+
queryset = SearchFilterModel.objects.all()
332+
serializer_class = SearchFilterSerializer
333+
filter_backends = (filters.SearchFilter,)
334+
search_fields = ('@title',)
335+
336+
view = SearchListView.as_view()
337+
request = factory.get('/', {'search': 'elephant'})
338+
response = view(request)
339+
assert len(response.data) == 0
340+
341+
def test_full_text_search_multiple_fields(self):
342+
class SearchListView(generics.ListAPIView):
343+
queryset = SearchFilterModel.objects.all()
344+
serializer_class = SearchFilterSerializer
345+
filter_backends = (filters.SearchFilter,)
346+
search_fields = ('@title', '@text')
347+
348+
view = SearchListView.as_view()
349+
request = factory.get('/', {'search': 'lazy'})
350+
response = view(request)
351+
assert len(response.data) == 1
352+
assert response.data[0]['title'] == 'The quick brown fox'
353+
354+
def test_full_text_search_stemming(self):
355+
"""Full text search should match stemmed words (e.g. 'jumping' matches 'jumps')."""
356+
class SearchListView(generics.ListAPIView):
357+
queryset = SearchFilterModel.objects.all()
358+
serializer_class = SearchFilterSerializer
359+
filter_backends = (filters.SearchFilter,)
360+
search_fields = ('@text',)
361+
362+
view = SearchListView.as_view()
363+
request = factory.get('/', {'search': 'jumping'})
364+
response = view(request)
365+
assert len(response.data) == 1
366+
assert response.data[0]['text'] == 'jumps over the lazy dog'
367+
368+
def test_full_text_search_multiple_terms(self):
369+
"""Each search term must match (AND semantics across terms)."""
370+
class SearchListView(generics.ListAPIView):
371+
queryset = SearchFilterModel.objects.all()
372+
serializer_class = SearchFilterSerializer
373+
filter_backends = (filters.SearchFilter,)
374+
search_fields = ('@title', '@text')
375+
376+
view = SearchListView.as_view()
377+
request = factory.get('/', {'search': 'brown lazy'})
378+
response = view(request)
379+
assert len(response.data) == 1
380+
assert response.data[0]['title'] == 'The quick brown fox'
381+
382+
def test_full_text_search_mixed_with_icontains(self):
383+
"""Full text search fields can be mixed with regular icontains fields."""
384+
class SearchListView(generics.ListAPIView):
385+
queryset = SearchFilterModel.objects.all()
386+
serializer_class = SearchFilterSerializer
387+
filter_backends = (filters.SearchFilter,)
388+
search_fields = ('@title', 'text')
389+
390+
view = SearchListView.as_view()
391+
request = factory.get('/', {'search': 'park'})
392+
response = view(request)
393+
assert len(response.data) == 1
394+
assert response.data[0]['title'] == 'A bright sunny day'
395+
396+
294397
class AttributeModel(models.Model):
295398
label = models.CharField(max_length=32)
296399

@@ -339,6 +442,10 @@ def test_custom_lookup_to_related_model(self):
339442
assert 'attribute__label__icontains' == filter_.construct_search('attribute__label', SearchFilterModelFk._meta)
340443
assert 'attribute__label__iendswith' == filter_.construct_search('attribute__label__iendswith', SearchFilterModelFk._meta)
341444

445+
def test_construct_search_with_at_prefix(self):
446+
filter_ = filters.SearchFilter()
447+
assert 'title__search' == filter_.construct_search('@title', SearchFilterModelFk._meta)
448+
342449

343450
class SearchFilterModelM2M(models.Model):
344451
title = models.CharField(max_length=20)

0 commit comments

Comments
 (0)