From a36df6890d8995480f2e95ba556b77cef975d4f6 Mon Sep 17 00:00:00 2001 From: Samriddha9619 Date: Mon, 8 Sep 2025 18:26:32 +0530 Subject: [PATCH 001/116] Fixed #36488 -- Fixed merging of query strings in RedirectView. Co-authored-by: Ethan Jucovy Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> --- django/views/generic/base.py | 6 +++++- tests/generic_views/test_base.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/django/views/generic/base.py b/django/views/generic/base.py index 8412288be1..485b74d377 100644 --- a/django/views/generic/base.py +++ b/django/views/generic/base.py @@ -1,4 +1,5 @@ import logging +from urllib.parse import urlparse from asgiref.sync import iscoroutinefunction, markcoroutinefunction @@ -252,7 +253,10 @@ class RedirectView(View): args = self.request.META.get("QUERY_STRING", "") if args and self.query_string: - url = "%s?%s" % (url, args) + if urlparse(url).query: + url = f"{url}&{args}" + else: + url = f"{url}?{args}" return url def get(self, request, *args, **kwargs): diff --git a/tests/generic_views/test_base.py b/tests/generic_views/test_base.py index cc5dcf4e39..9d6cef8a46 100644 --- a/tests/generic_views/test_base.py +++ b/tests/generic_views/test_base.py @@ -587,6 +587,31 @@ class RedirectViewTest(LoggingAssertionMixin, SimpleTestCase): handler, f"Gone: {escaped}", logging.WARNING, 410, request ) + def test_redirect_with_querry_string_in_destination(self): + response = RedirectView.as_view(url="/bar/?pork=spam", query_string=True)( + self.rf.get("/foo") + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], "/bar/?pork=spam") + + def test_redirect_with_query_string_in_destination_and_request(self): + response = RedirectView.as_view(url="/bar/?pork=spam", query_string=True)( + self.rf.get("/foo/?utm_source=social") + ) + self.assertEqual(response.status_code, 302) + self.assertEqual( + response.headers["Location"], "/bar/?pork=spam&utm_source=social" + ) + + def test_redirect_with_same_query_string_param_will_append_not_replace(self): + response = RedirectView.as_view(url="/bar/?pork=spam", query_string=True)( + self.rf.get("/foo/?utm_source=social&pork=ham") + ) + self.assertEqual(response.status_code, 302) + self.assertEqual( + response.headers["Location"], "/bar/?pork=spam&utm_source=social&pork=ham" + ) + class GetContextDataTest(SimpleTestCase): def test_get_context_data_super(self): From af84cfba5970fda8306860b650937701c7c03c6f Mon Sep 17 00:00:00 2001 From: Simon Charette Date: Wed, 17 Sep 2025 01:56:40 -0400 Subject: [PATCH 002/116] Fixed #36612 -- Fixed a KeyTextTransform crash on MySQL against annotations. MySQL only supports the ->> when used directly against columns, this can be inferred by the presence of lhs.output_field.model as model bounds fields are directly tied to columns. Purposely don't systematically switch to using JSON_QUOTE(JSON_EXTRACT(...)) as there might be functional indices out there that rely on the SQL remaining stable between versions. Thanks Jacob Tavener for the report. --- django/db/models/fields/json.py | 8 ++++++-- tests/model_fields/test_jsonfield.py | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/django/db/models/fields/json.py b/django/db/models/fields/json.py index d544caf3e5..e0aa5c622b 100644 --- a/django/db/models/fields/json.py +++ b/django/db/models/fields/json.py @@ -424,8 +424,12 @@ class KeyTextTransform(KeyTransform): output_field = TextField() def as_mysql(self, compiler, connection): - if connection.mysql_is_mariadb: - # MariaDB doesn't support -> and ->> operators (see MDEV-13594). + # The ->> operator is not supported on MariaDB (see MDEV-13594) and + # only supported against columns on MySQL. + if ( + connection.mysql_is_mariadb + or getattr(self.lhs.output_field, "model", None) is None + ): sql, params = super().as_mysql(compiler, connection) return "JSON_UNQUOTE(%s)" % sql, params else: diff --git a/tests/model_fields/test_jsonfield.py b/tests/model_fields/test_jsonfield.py index 16ab8887a9..b16499d198 100644 --- a/tests/model_fields/test_jsonfield.py +++ b/tests/model_fields/test_jsonfield.py @@ -1160,6 +1160,12 @@ class TestQuerying(TestCase): True, ) + def test_cast_with_key_text_transform(self): + obj = NullableJSONModel.objects.annotate( + json_data=Cast(Value({"foo": "bar"}, JSONField()), JSONField()) + ).get(pk=self.objs[0].pk, json_data__foo__icontains="bar") + self.assertEqual(obj, self.objs[0]) + @skipUnlessDBFeature("supports_json_field_contains") def test_contains_contained_by_with_key_transform(self): tests = [ From dce1b9c2de00a3385c029c02dca325f44e7697a4 Mon Sep 17 00:00:00 2001 From: Shubham Singh Date: Thu, 11 Sep 2025 14:47:05 -0500 Subject: [PATCH 003/116] Fixed #36480 -- Made values() resolving error mention unselected aliases. Follow-up to cb13792938f2c887134eb6b5164d89f8d8f9f1bd. Refs #34437. --- django/db/models/sql/query.py | 2 +- tests/annotations/tests.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py index fb1f831042..39ecab2e91 100644 --- a/django/db/models/sql/query.py +++ b/django/db/models/sql/query.py @@ -1817,7 +1817,7 @@ class Query(BaseExpression): available = sorted( [ *get_field_names_from_opts(opts), - *self.annotation_select, + *self.annotations, *self._filtered_relations, ] ) diff --git a/tests/annotations/tests.py b/tests/annotations/tests.py index 8091498908..cf1eebf8d7 100644 --- a/tests/annotations/tests.py +++ b/tests/annotations/tests.py @@ -1539,3 +1539,13 @@ class AliasTests(TestCase): ) with self.assertRaisesMessage(ValueError, msg): Book.objects.alias(**{crafted_alias: FilteredRelation("authors")}) + + def test_values_wrong_alias(self): + expected_message = ( + "Cannot resolve keyword 'alias_typo' into field. Choices are: %s" + ) + alias_fields = ", ".join( + sorted(["my_alias"] + list(get_field_names_from_opts(Book._meta))) + ) + with self.assertRaisesMessage(FieldError, expected_message % alias_fields): + Book.objects.alias(my_alias=F("pk")).order_by("alias_typo") From f9a44cc0fac653f8e0c2ab1cdfb12b2cc5c63fc2 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Fri, 27 Jun 2025 13:03:16 -0700 Subject: [PATCH 004/116] Fixed #35453 -- Made ManyToManyField.concrete False. ManyToManyField was already excluded from fields, concrete_fields, and local_concrete_fields in Options. --- django/db/backends/base/schema.py | 10 ++++++--- django/db/backends/sqlite3/schema.py | 2 +- django/db/models/fields/related.py | 4 ++++ django/db/models/query.py | 6 ++--- django/db/models/sql/subqueries.py | 6 ++--- tests/composite_pk/test_update.py | 2 +- tests/model_fields/test_field_flags.py | 3 ++- tests/update/tests.py | 31 +++++++++----------------- 8 files changed, 31 insertions(+), 33 deletions(-) diff --git a/django/db/backends/base/schema.py b/django/db/backends/base/schema.py index cc33740195..96d555f862 100644 --- a/django/db/backends/base/schema.py +++ b/django/db/backends/base/schema.py @@ -1660,7 +1660,9 @@ class BaseDatabaseSchemaEditor: return output def _field_should_be_altered(self, old_field, new_field, ignore=None): - if not old_field.concrete and not new_field.concrete: + if (not (old_field.concrete or old_field.many_to_many)) and ( + not (new_field.concrete or new_field.many_to_many) + ): return False ignore = ignore or set() _, old_path, old_args, old_kwargs = old_field.deconstruct() @@ -1692,8 +1694,10 @@ class BaseDatabaseSchemaEditor: ): old_kwargs.pop("db_default") new_kwargs.pop("db_default") - return self.quote_name(old_field.column) != self.quote_name( - new_field.column + return ( + old_field.concrete + and new_field.concrete + and (self.quote_name(old_field.column) != self.quote_name(new_field.column)) ) or (old_path, old_args, old_kwargs) != (new_path, new_args, new_kwargs) def _field_should_be_indexed(self, model, field): diff --git a/django/db/backends/sqlite3/schema.py b/django/db/backends/sqlite3/schema.py index ac6ae5efbd..077a53bf55 100644 --- a/django/db/backends/sqlite3/schema.py +++ b/django/db/backends/sqlite3/schema.py @@ -144,7 +144,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): # Choose a default and insert it into the copy map if ( not create_field.has_db_default() - and not (create_field.many_to_many or create_field.generated) + and not create_field.generated and create_field.concrete ): mapping[create_field.column] = self.prepare_default( diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 9ad440128e..a71ae2f401 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -1849,6 +1849,10 @@ class ManyToManyField(RelatedField): ) return name, path, args, kwargs + def get_attname_column(self): + attname, _ = super().get_attname_column() + return attname, None + def _get_path_info(self, direct=False, filtered_relation=None): """Called by both direct and indirect m2m traversal.""" int_model = self.remote_field.through diff --git a/django/db/models/query.py b/django/db/models/query.py index bd79e4bf36..721bf33e57 100644 --- a/django/db/models/query.py +++ b/django/db/models/query.py @@ -725,7 +725,7 @@ class QuerySet(AltersData): "Unique fields that can trigger the upsert must be provided." ) # Updating primary keys and non-concrete fields is forbidden. - if any(not f.concrete or f.many_to_many for f in update_fields): + if any(not f.concrete for f in update_fields): raise ValueError( "bulk_create() can only be used with concrete fields in " "update_fields." @@ -736,7 +736,7 @@ class QuerySet(AltersData): "update_fields." ) if unique_fields: - if any(not f.concrete or f.many_to_many for f in unique_fields): + if any(not f.concrete for f in unique_fields): raise ValueError( "bulk_create() can only be used with concrete fields " "in unique_fields." @@ -916,7 +916,7 @@ class QuerySet(AltersData): raise ValueError("All bulk_update() objects must have a primary key set.") opts = self.model._meta fields = [opts.get_field(name) for name in fields] - if any(not f.concrete or f.many_to_many for f in fields): + if any(not f.concrete for f in fields): raise ValueError("bulk_update() can only be used with concrete fields.") all_pk_fields = set(opts.pk_fields) for parent in opts.all_parents: diff --git a/django/db/models/sql/subqueries.py b/django/db/models/sql/subqueries.py index 9936f7ff42..9302247648 100644 --- a/django/db/models/sql/subqueries.py +++ b/django/db/models/sql/subqueries.py @@ -92,10 +92,10 @@ class UpdateQuery(Query): raise FieldError( "Composite primary key fields must be updated individually." ) - if not field.concrete or (field.is_relation and field.many_to_many): + if not field.concrete: raise FieldError( - "Cannot update model field %r (only non-relations and " - "foreign keys permitted)." % field + "Cannot update model field %r (only concrete fields are permitted)." + % field ) if model is not self.get_meta().concrete_model: self.add_related_update(model, field, val) diff --git a/tests/composite_pk/test_update.py b/tests/composite_pk/test_update.py index 8d786e8afb..4f3068f228 100644 --- a/tests/composite_pk/test_update.py +++ b/tests/composite_pk/test_update.py @@ -172,7 +172,7 @@ class CompositePKUpdateTests(TestCase): def test_cant_update_relation(self): msg = ( "Cannot update model field (only non-relations and foreign keys permitted)" + "user> (only concrete fields are permitted)" ) with self.assertRaisesMessage(FieldError, msg): diff --git a/tests/model_fields/test_field_flags.py b/tests/model_fields/test_field_flags.py index 33f3334567..778a43eb63 100644 --- a/tests/model_fields/test_field_flags.py +++ b/tests/model_fields/test_field_flags.py @@ -6,6 +6,7 @@ from .models import AllFieldsModel NON_CONCRETE_FIELDS = ( models.ForeignObject, + models.ManyToManyField, GenericForeignKey, GenericRelation, ) @@ -209,7 +210,7 @@ class FieldFlagsTests(test.SimpleTestCase): def test_model_and_reverse_model_should_equal_on_relations(self): for field in AllFieldsModel._meta.get_fields(): is_concrete_forward_field = field.concrete and field.related_model - if is_concrete_forward_field: + if is_concrete_forward_field or field.many_to_many: reverse_field = field.remote_field self.assertEqual(field.model, reverse_field.related_model) self.assertEqual(field.related_model, reverse_field.model) diff --git a/tests/update/tests.py b/tests/update/tests.py index af5939a2ef..eb5d80219c 100644 --- a/tests/update/tests.py +++ b/tests/update/tests.py @@ -157,43 +157,32 @@ class AdvancedTests(TestCase): self.assertEqual(bar_qs[0].foo_id, b_foo.target) def test_update_m2m_field(self): - msg = ( - "Cannot update model field " - " " - "(only non-relations and foreign keys permitted)." - ) + rel = "" + msg = f"Cannot update model field {rel} (only concrete fields are permitted)." with self.assertRaisesMessage(FieldError, msg): Bar.objects.update(m2m_foo="whatever") def test_update_reverse_m2m_descriptor(self): - msg = ( - "Cannot update model field " - "(only non-relations and foreign keys permitted)." - ) + rel = "" + msg = f"Cannot update model field {rel} (only concrete fields are permitted)." with self.assertRaisesMessage(FieldError, msg): Foo.objects.update(m2m_foo="whatever") def test_update_reverse_fk_descriptor(self): - msg = ( - "Cannot update model field " - "(only non-relations and foreign keys permitted)." - ) + rel = "" + msg = f"Cannot update model field {rel} (only concrete fields are permitted)." with self.assertRaisesMessage(FieldError, msg): Foo.objects.update(bar="whatever") def test_update_reverse_o2o_descriptor(self): - msg = ( - "Cannot update model field " - "(only non-relations and foreign keys permitted)." - ) + rel = "" + msg = f"Cannot update model field {rel} (only concrete fields are permitted)." with self.assertRaisesMessage(FieldError, msg): Foo.objects.update(o2o_bar="whatever") def test_update_reverse_mti_parent_link_descriptor(self): - msg = ( - "Cannot update model field " - "(only non-relations and foreign keys permitted)." - ) + rel = "" + msg = f"Cannot update model field {rel} (only concrete fields are permitted)." with self.assertRaisesMessage(FieldError, msg): UniqueNumber.objects.update(uniquenumberchild="whatever") From 6fe96639baf656db166997096bddec0f5c76fc65 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Mon, 22 Sep 2025 17:25:19 +0200 Subject: [PATCH 005/116] Bumped versions in pre-commit and npm configurations. --- .pre-commit-config.yaml | 10 +++++----- package.json | 6 +++--- tests/requirements/py3.txt | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b1faf024d4..e1d8cec10b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,15 @@ repos: - repo: https://github.com/psf/black-pre-commit-mirror - rev: 25.1.0 + rev: 25.9.0 hooks: - id: black exclude: \.py-tpl$ - repo: https://github.com/adamchainz/blacken-docs - rev: 1.19.1 + rev: 1.20.0 hooks: - id: blacken-docs additional_dependencies: - - black==25.1.0 + - black==25.9.0 files: 'docs/.*\.txt$' args: ["--rst-literal-block"] - repo: https://github.com/PyCQA/isort @@ -17,10 +17,10 @@ repos: hooks: - id: isort - repo: https://github.com/PyCQA/flake8 - rev: 7.2.0 + rev: 7.3.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-eslint - rev: v9.24.0 + rev: v9.36.0 hooks: - id: eslint diff --git a/package.json b/package.json index aead711287..396ffd8653 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ "npm": ">=1.3.0" }, "devDependencies": { - "eslint": "^9.24.0", - "puppeteer": "^24.6.1", - "globals": "^16.0.0", + "eslint": "^9.36.0", + "puppeteer": "^24.22.0", + "globals": "^16.4.0", "grunt": "^1.6.1", "grunt-cli": "^1.5.0", "grunt-contrib-qunit": "^10.1.1", diff --git a/tests/requirements/py3.txt b/tests/requirements/py3.txt index 7ecc3ad044..1a16cc0440 100644 --- a/tests/requirements/py3.txt +++ b/tests/requirements/py3.txt @@ -2,7 +2,7 @@ aiosmtpd >= 1.4.5 asgiref >= 3.9.1 argon2-cffi >= 23.1.0 bcrypt >= 4.1.1 -black >= 25.1.0 +black >= 25.9.0 docutils >= 0.19 geoip2 >= 4.8.0 jinja2 >= 2.11.0 From e20e1890450011693df845394e0a133a202b5466 Mon Sep 17 00:00:00 2001 From: David Smith Date: Sun, 29 Sep 2024 16:53:46 +0100 Subject: [PATCH 006/116] Refs #33783 -- Added IsEmpty GIS database function and __isempty lookup on SpatiaLite. --- .../gis/db/backends/spatialite/operations.py | 3 ++- django/contrib/gis/db/models/functions.py | 4 ++++ docs/ref/contrib/gis/db-api.txt | 4 ++-- docs/ref/contrib/gis/functions.txt | 7 ++++++- docs/ref/contrib/gis/geoquerysets.txt | 7 ++++++- docs/releases/6.1.txt | 4 +++- tests/gis_tests/geoapp/test_functions.py | 14 +++++++++++++- 7 files changed, 36 insertions(+), 7 deletions(-) diff --git a/django/contrib/gis/db/backends/spatialite/operations.py b/django/contrib/gis/db/backends/spatialite/operations.py index e9321ee2a3..54ec249f07 100644 --- a/django/contrib/gis/db/backends/spatialite/operations.py +++ b/django/contrib/gis/db/backends/spatialite/operations.py @@ -73,6 +73,7 @@ class SpatiaLiteOperations(BaseSpatialOperations, DatabaseOperations): "ForcePolygonCW": "ST_ForceLHR", "FromWKB": "ST_GeomFromWKB", "FromWKT": "ST_GeomFromText", + "IsEmpty": "ST_IsEmpty", "Length": "ST_Length", "LineLocatePoint": "ST_Line_Locate_Point", "NumPoints": "ST_NPoints", @@ -84,7 +85,7 @@ class SpatiaLiteOperations(BaseSpatialOperations, DatabaseOperations): @cached_property def unsupported_functions(self): - unsupported = {"GeometryDistance", "IsEmpty", "MemSize", "Rotate"} + unsupported = {"GeometryDistance", "MemSize", "Rotate"} if not self.geom_lib_version(): unsupported |= {"Azimuth", "GeoHash", "MakeValid"} if self.spatial_version < (5, 1): diff --git a/django/contrib/gis/db/models/functions.py b/django/contrib/gis/db/models/functions.py index 9e94d0f77a..b6ff35858f 100644 --- a/django/contrib/gis/db/models/functions.py +++ b/django/contrib/gis/db/models/functions.py @@ -451,6 +451,10 @@ class IsEmpty(GeoFuncMixin, Transform): lookup_name = "isempty" output_field = BooleanField() + def as_sqlite(self, compiler, connection, **extra_context): + sql, params = super().as_sql(compiler, connection, **extra_context) + return "NULLIF(%s, -1)" % sql, params + @BaseSpatialField.register_lookup class IsValid(OracleToleranceMixin, GeoFuncMixin, Transform): diff --git a/docs/ref/contrib/gis/db-api.txt b/docs/ref/contrib/gis/db-api.txt index e23a7667f1..b51001ecd1 100644 --- a/docs/ref/contrib/gis/db-api.txt +++ b/docs/ref/contrib/gis/db-api.txt @@ -363,7 +363,7 @@ Lookup Type PostGIS Oracle MariaDB MySQL [#]_ :lookup:`exact ` X X X X X B :lookup:`geom_type` X X (≥ 23c) X X X :lookup:`intersects` X X X X X B -:lookup:`isempty` X +:lookup:`isempty` X X :lookup:`isvalid` X X X (≥ 12.0.1) X X :lookup:`overlaps` X X X X X B :lookup:`relate` X X X X C @@ -414,7 +414,7 @@ Function PostGIS Oracle MariaDB MySQL :class:`GeometryDistance` X :class:`GeometryType` X X (≥ 23c) X X X :class:`Intersection` X X X X X -:class:`IsEmpty` X +:class:`IsEmpty` X X :class:`IsValid` X X X (≥ 12.0.1) X X :class:`Length` X X X X X :class:`LineLocatePoint` X X diff --git a/docs/ref/contrib/gis/functions.txt b/docs/ref/contrib/gis/functions.txt index af1cd439dc..ab540627eb 100644 --- a/docs/ref/contrib/gis/functions.txt +++ b/docs/ref/contrib/gis/functions.txt @@ -621,11 +621,16 @@ Miscellaneous .. class:: IsEmpty(expr) -*Availability*: `PostGIS `__ +*Availability*: `PostGIS `__, +SpatiaLite Accepts a geographic field or expression and tests if the value is an empty geometry. Returns ``True`` if its value is empty and ``False`` otherwise. +.. versionchanged:: 6.1 + + SpatiaLite support was added. + ``IsValid`` ----------- diff --git a/docs/ref/contrib/gis/geoquerysets.txt b/docs/ref/contrib/gis/geoquerysets.txt index d6d477fb9c..6109bafb4f 100644 --- a/docs/ref/contrib/gis/geoquerysets.txt +++ b/docs/ref/contrib/gis/geoquerysets.txt @@ -361,7 +361,8 @@ SpatiaLite ``Intersects(poly, geom)`` ``isempty`` ----------- -*Availability*: `PostGIS `__ +*Availability*: `PostGIS `__, +SpatiaLite Tests if the geometry is empty. @@ -369,6 +370,10 @@ Example:: Zipcode.objects.filter(poly__isempty=True) +.. versionchanged:: 6.1 + + SpatiaLite support was added. + .. fieldlookup:: isvalid ``isvalid`` diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 309a775e74..be05350d94 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -53,7 +53,9 @@ Minor features :mod:`django.contrib.gis` ~~~~~~~~~~~~~~~~~~~~~~~~~ -* ... +* The :lookup:`isempty` lookup and + :class:`IsEmpty() ` + database function are now supported on SpatiaLite. :mod:`django.contrib.messages` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/gis_tests/geoapp/test_functions.py b/tests/gis_tests/geoapp/test_functions.py index 70c462a78e..b1ab4340aa 100644 --- a/tests/gis_tests/geoapp/test_functions.py +++ b/tests/gis_tests/geoapp/test_functions.py @@ -431,7 +431,7 @@ class GISFunctionsTests(FuncTestMixin, TestCase): self.assertIs(c.inter.empty, True) @skipUnlessDBFeature("supports_empty_geometries", "has_IsEmpty_function") - def test_isempty(self): + def test_isempty_geometry_empty(self): empty = City.objects.create(name="Nowhere", point=Point(srid=4326)) City.objects.create(name="Somewhere", point=Point(6.825, 47.1, srid=4326)) self.assertSequenceEqual( @@ -442,6 +442,18 @@ class GISFunctionsTests(FuncTestMixin, TestCase): ) self.assertSequenceEqual(City.objects.filter(point__isempty=True), [empty]) + @skipUnlessDBFeature("has_IsEmpty_function") + def test_isempty_geometry_null(self): + nowhere = State.objects.create(name="Nowhere", poly=None) + qs = State.objects.annotate(isempty=functions.IsEmpty("poly")) + self.assertSequenceEqual(qs.filter(isempty=None), [nowhere]) + self.assertSequenceEqual( + qs.filter(isempty=False).order_by("name").values_list("name", flat=True), + ["Colorado", "Kansas"], + ) + self.assertSequenceEqual(qs.filter(isempty=True), []) + self.assertSequenceEqual(State.objects.filter(poly__isempty=True), []) + @skipUnlessDBFeature("has_IsValid_function") def test_isvalid(self): valid_geom = fromstr("POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))") From 30e9b6f6adfed9ee4c1fa911956881a2361c8946 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Tue, 23 Sep 2025 08:01:23 +0200 Subject: [PATCH 007/116] Fixed warning in "New contributor" GitHub action. --- .github/workflows/new_contributor_pr.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/new_contributor_pr.yml b/.github/workflows/new_contributor_pr.yml index e294c3fab5..69f637bfac 100644 --- a/.github/workflows/new_contributor_pr.yml +++ b/.github/workflows/new_contributor_pr.yml @@ -14,8 +14,8 @@ jobs: steps: - uses: actions/first-interaction@v3 with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - pr-message: | + repo_token: ${{ secrets.GITHUB_TOKEN }} + pr_message: | Hello! Thank you for your contribution 💪 As it's your first contribution be sure to check out the [patch review checklist](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/submitting-patches/#patch-review-checklist). From 9af8225117bbc845a41ca27332c0ee1946322b90 Mon Sep 17 00:00:00 2001 From: Jean Patrick Prenis Date: Sat, 13 Sep 2025 10:13:46 -0500 Subject: [PATCH 008/116] Fixed #36609 -- Added Haitian Creole (ht) language. Thanks Rebecca Conley for the review. Co-Authored-By: Mariusz Felisiak --- django/conf/global_settings.py | 1 + django/conf/locale/__init__.py | 6 +++ django/conf/locale/en/LC_MESSAGES/django.po | 4 ++ django/conf/locale/ht/__init__.py | 0 django/conf/locale/ht/formats.py | 48 +++++++++++++++++++++ docs/releases/6.0.txt | 5 +++ 6 files changed, 64 insertions(+) create mode 100644 django/conf/locale/ht/__init__.py create mode 100644 django/conf/locale/ht/formats.py diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 72f84dd6af..72c376dd78 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -92,6 +92,7 @@ LANGUAGES = [ ("hi", gettext_noop("Hindi")), ("hr", gettext_noop("Croatian")), ("hsb", gettext_noop("Upper Sorbian")), + ("ht", gettext_noop("Haitian Creole")), ("hu", gettext_noop("Hungarian")), ("hy", gettext_noop("Armenian")), ("ia", gettext_noop("Interlingua")), diff --git a/django/conf/locale/__init__.py b/django/conf/locale/__init__.py index 04962042b3..41dee940f3 100644 --- a/django/conf/locale/__init__.py +++ b/django/conf/locale/__init__.py @@ -255,6 +255,12 @@ LANG_INFO = { "name": "Upper Sorbian", "name_local": "hornjoserbsce", }, + "ht": { + "bidi": False, + "code": "ht", + "name": "Haitian Creole", + "name_local": "Kreyòl Ayisyen", + }, "hu": { "bidi": False, "code": "hu", diff --git a/django/conf/locale/en/LC_MESSAGES/django.po b/django/conf/locale/en/LC_MESSAGES/django.po index da1bef215d..4d4ee487f6 100644 --- a/django/conf/locale/en/LC_MESSAGES/django.po +++ b/django/conf/locale/en/LC_MESSAGES/django.po @@ -178,6 +178,10 @@ msgstr "" msgid "Upper Sorbian" msgstr "" +#: conf/global_settings.py:95 +msgid "Haitian Creole" +msgstr "" + #: conf/global_settings.py:95 msgid "Hungarian" msgstr "" diff --git a/django/conf/locale/ht/__init__.py b/django/conf/locale/ht/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/django/conf/locale/ht/formats.py b/django/conf/locale/ht/formats.py new file mode 100644 index 0000000000..1a1c70f395 --- /dev/null +++ b/django/conf/locale/ht/formats.py @@ -0,0 +1,48 @@ +# This file is distributed under the same license as the Django package. +# +# The *_FORMAT strings use the Django date format syntax, +# see https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date +DATE_FORMAT = "N j, Y" +TIME_FORMAT = "P" +DATETIME_FORMAT = "N j, Y, P" +YEAR_MONTH_FORMAT = "F Y" +MONTH_DAY_FORMAT = "F j" +SHORT_DATE_FORMAT = "d/m/Y" +SHORT_DATETIME_FORMAT = "d/m/Y P" +FIRST_DAY_OF_WEEK = 0 + +# The *_INPUT_FORMATS strings use the Python strftime format syntax, +# see https://docs.python.org/library/datetime.html#strftime-strptime-behavior +DATE_INPUT_FORMATS = [ + "%Y-%m-%d", # '2006-10-25' + "%m/%d/%Y", # '10/25/2006' + "%m/%d/%y", # '10/25/06' + "%b %d %Y", # 'Oct 25 2006' + "%b %d, %Y", # 'Oct 25, 2006' + "%d %b %Y", # '25 Oct 2006' + "%d %b, %Y", # '25 Oct, 2006' + "%B %d %Y", # 'October 25 2006' + "%B %d, %Y", # 'October 25, 2006' + "%d %B %Y", # '25 October 2006' + "%d %B, %Y", # '25 October, 2006' +] +DATETIME_INPUT_FORMATS = [ + "%Y-%m-%d %H:%M:%S", # '2006-10-25 14:30:59' + "%Y-%m-%d %H:%M:%S.%f", # '2006-10-25 14:30:59.000200' + "%Y-%m-%d %H:%M", # '2006-10-25 14:30' + "%m/%d/%Y %H:%M:%S", # '10/25/2006 14:30:59' + "%m/%d/%Y %H:%M:%S.%f", # '10/25/2006 14:30:59.000200' + "%m/%d/%Y %H:%M", # '10/25/2006 14:30' + "%m/%d/%y %H:%M:%S", # '10/25/06 14:30:59' + "%m/%d/%y %H:%M:%S.%f", # '10/25/06 14:30:59.000200' + "%m/%d/%y %H:%M", # '10/25/06 14:30' +] +TIME_INPUT_FORMATS = [ + "%H:%M:%S", # '14:30:59' + "%H:%M:%S.%f", # '14:30:59.000200' + "%H:%M", # '14:30' +] + +DECIMAL_SEPARATOR = "," +THOUSAND_SEPARATOR = "\xa0" +NUMBER_GROUPING = 3 diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index 268d7a38c0..e1cdc7fc67 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -229,6 +229,11 @@ Email accepts a :class:`~email.message.MIMEPart` object from Python's modern email API. +Internationalization +~~~~~~~~~~~~~~~~~~~~ + +* Added support and translations for the Haitian Creole language. + Management Commands ~~~~~~~~~~~~~~~~~~~ From efb96138b4af774c22ae6e949410b45d69960357 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Tue, 23 Sep 2025 07:14:31 -0400 Subject: [PATCH 009/116] Refs #25508 -- Used QuerySet.__repr__ in docs/ref/contrib/postgres/search.txt. --- docs/ref/contrib/postgres/search.txt | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/ref/contrib/postgres/search.txt b/docs/ref/contrib/postgres/search.txt index 88e3cfaeb0..0d14bc8c05 100644 --- a/docs/ref/contrib/postgres/search.txt +++ b/docs/ref/contrib/postgres/search.txt @@ -27,7 +27,7 @@ single column in the database. For example: .. code-block:: pycon >>> Entry.objects.filter(body_text__search="Cheese") - [, ] + , ]> This creates a ``to_tsvector`` in the database from the ``body_text`` field and a ``plainto_tsquery`` from the search term ``'Cheese'``, both using the @@ -52,7 +52,7 @@ To query against both fields, use a ``SearchVector``: >>> Entry.objects.annotate( ... search=SearchVector("body_text", "blog__tagline"), ... ).filter(search="Cheese") - [, ] + , ]> The arguments to ``SearchVector`` can be any :class:`~django.db.models.Expression` or the name of a field. Multiple @@ -67,7 +67,7 @@ For example: >>> Entry.objects.annotate( ... search=SearchVector("body_text") + SearchVector("blog__tagline"), ... ).filter(search="Cheese") - [, ] + , ]> See :ref:`postgresql-fts-search-configuration` and :ref:`postgresql-fts-weighting-queries` for an explanation of the ``config`` @@ -142,7 +142,7 @@ order by relevancy: >>> vector = SearchVector("body_text") >>> query = SearchQuery("cheese") >>> Entry.objects.annotate(rank=SearchRank(vector, query)).order_by("-rank") - [, ] + , ]> See :ref:`postgresql-fts-weighting-queries` for an explanation of the ``weights`` parameter. @@ -240,7 +240,7 @@ different language parsers and dictionaries as defined by the database: >>> Entry.objects.annotate( ... search=SearchVector("body_text", config="french"), ... ).filter(search=SearchQuery("œuf", config="french")) - [] + ]> The value of ``config`` could also be stored in another column: @@ -250,7 +250,7 @@ The value of ``config`` could also be stored in another column: >>> Entry.objects.annotate( ... search=SearchVector("body_text", config=F("blog__language")), ... ).filter(search=SearchQuery("œuf", config=F("blog__language"))) - [] + ]> .. _postgresql-fts-weighting-queries: @@ -364,7 +364,7 @@ if it were an annotated ``SearchVector``: >>> Entry.objects.update(search_vector=SearchVector("body_text")) >>> Entry.objects.filter(search_vector="cheese") - [, ] + , ]> .. _PostgreSQL documentation: https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS @@ -403,7 +403,7 @@ Usage example: ... ).filter( ... similarity__gt=0.3 ... ).order_by("-similarity") - [, ] + , ]> ``TrigramWordSimilarity`` ------------------------- @@ -426,7 +426,7 @@ Usage example: ... ).filter( ... similarity__gt=0.3 ... ).order_by("-similarity") - [] + ]> ``TrigramStrictWordSimilarity`` ------------------------------- @@ -459,7 +459,7 @@ Usage example: ... ).filter( ... distance__lte=0.7 ... ).order_by("distance") - [, ] + , ]> ``TrigramWordDistance`` ----------------------- @@ -482,7 +482,7 @@ Usage example: ... ).filter( ... distance__lte=0.7 ... ).order_by("distance") - [] + ]> ``TrigramStrictWordDistance`` ----------------------------- From 748551fea0b4e37231203a063356572a47e09efb Mon Sep 17 00:00:00 2001 From: saJaeHyukc Date: Tue, 2 Sep 2025 14:16:30 +0900 Subject: [PATCH 010/116] Fixed #36264 -- Excluded proxy neighbors of parents from deletion collection when keep_parents=True. Signed-off-by: saJaeHyukc --- django/db/models/deletion.py | 5 ++++- tests/delete/models.py | 7 ++++++- tests/delete/tests.py | 9 +++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/django/db/models/deletion.py b/django/db/models/deletion.py index b1939f8b35..8d3fa5c92c 100644 --- a/django/db/models/deletion.py +++ b/django/db/models/deletion.py @@ -309,7 +309,10 @@ class Collector: protected_objects = defaultdict(list) for related in get_candidate_relations_to_delete(model._meta): # Preserve parent reverse relationships if keep_parents=True. - if keep_parents and related.model in model._meta.all_parents: + if ( + keep_parents + and related.model._meta.concrete_model in model._meta.all_parents + ): continue field = related.field on_delete = field.remote_field.on_delete diff --git a/tests/delete/models.py b/tests/delete/models.py index 4b627712bb..7f123b3396 100644 --- a/tests/delete/models.py +++ b/tests/delete/models.py @@ -32,6 +32,11 @@ class RChild(R): pass +class RProxy(R): + class Meta: + proxy = True + + class RChildChild(RChild): pass @@ -179,7 +184,7 @@ class RelToBase(models.Model): class Origin(models.Model): - pass + r_proxy = models.ForeignKey("RProxy", models.CASCADE, null=True) class Referrer(models.Model): diff --git a/tests/delete/tests.py b/tests/delete/tests.py index 7b9dcdb079..59140b5c62 100644 --- a/tests/delete/tests.py +++ b/tests/delete/tests.py @@ -34,6 +34,7 @@ from .models import ( RChild, RChildChild, Referrer, + RProxy, S, T, User, @@ -675,6 +676,14 @@ class DeletionTests(TestCase): ) signal.disconnect(receiver, sender=Referrer) + def test_keep_parents_does_not_delete_proxy_related(self): + r_child = RChild.objects.create() + r_proxy = RProxy.objects.get(pk=r_child.pk) + Origin.objects.create(r_proxy=r_proxy) + self.assertEqual(Origin.objects.count(), 1) + r_child.delete(keep_parents=True) + self.assertEqual(Origin.objects.count(), 1) + class FastDeleteTests(TestCase): def test_fast_delete_all(self): From b67a36ec6f5895f3fa6147264bae55cb014fa2a7 Mon Sep 17 00:00:00 2001 From: Mridul Dhall Date: Fri, 8 Aug 2025 17:12:31 +0100 Subject: [PATCH 011/116] Fixed #36543 -- Fixed time formats for fr_CA. Thanks Chris Anderson for the report. --- django/conf/locale/fr_CA/formats.py | 6 +++--- tests/i18n/tests.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/django/conf/locale/fr_CA/formats.py b/django/conf/locale/fr_CA/formats.py index 4f1a017f16..ecb45f5bbb 100644 --- a/django/conf/locale/fr_CA/formats.py +++ b/django/conf/locale/fr_CA/formats.py @@ -3,12 +3,12 @@ # The *_FORMAT strings use the Django date format syntax, # see https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date DATE_FORMAT = "j F Y" # 31 janvier 2024 -TIME_FORMAT = "H\xa0h\xa0i" # 13 h 40 -DATETIME_FORMAT = "j F Y, H\xa0h\xa0i" # 31 janvier 2024, 13 h 40 +TIME_FORMAT = "H\xa0\\h\xa0i" # 13 h 40 +DATETIME_FORMAT = "j F Y, H\xa0\\h\xa0i" # 31 janvier 2024, 13 h 40 YEAR_MONTH_FORMAT = "F Y" MONTH_DAY_FORMAT = "j F" SHORT_DATE_FORMAT = "Y-m-d" -SHORT_DATETIME_FORMAT = "Y-m-d H\xa0h\xa0i" +SHORT_DATETIME_FORMAT = "Y-m-d H\xa0\\h\xa0i" FIRST_DAY_OF_WEEK = 0 # Dimanche # The *_INPUT_FORMATS strings use the Python strftime format syntax, diff --git a/tests/i18n/tests.py b/tests/i18n/tests.py index b4bdf160d6..aac56f5df4 100644 --- a/tests/i18n/tests.py +++ b/tests/i18n/tests.py @@ -1158,6 +1158,27 @@ class FormattingTests(SimpleTestCase): ), ) + def test_uncommon_locale_formats(self): + testcases = { + # French Canadian locale uses 'h' as time format seperator. + ("fr-ca", time_format, (self.t, "TIME_FORMAT")): "10\xa0h\xa015", + ( + "fr-ca", + date_format, + (self.dt, "DATETIME_FORMAT"), + ): "31 décembre 2009, 20\xa0h\xa050", + ( + "fr-ca", + date_format, + (self.dt, "SHORT_DATETIME_FORMAT"), + ): "2009-12-31 20\xa0h\xa050", + } + for testcase, expected in testcases.items(): + locale, format_function, format_args = testcase + with self.subTest(locale=locale, expected=expected): + with translation.override(locale, deactivate=True): + self.assertEqual(expected, format_function(*format_args)) + def test_sub_locales(self): """ Check if sublocales fall back to the main locale From 1acb00b26da13165e967bf2354fc917e38c382e4 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 23 Sep 2025 12:36:49 -0400 Subject: [PATCH 012/116] Fixed #36616 -- Added DatabaseOperations.adapt_durationfield_value(). --- django/db/backends/base/operations.py | 11 +++++++++++ django/db/backends/oracle/operations.py | 3 +++ django/db/backends/postgresql/operations.py | 3 +++ django/db/models/fields/__init__.py | 8 ++------ docs/releases/6.1.txt | 4 +++- 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/django/db/backends/base/operations.py b/django/db/backends/base/operations.py index 16a6296f9b..9822a7fbb1 100644 --- a/django/db/backends/base/operations.py +++ b/django/db/backends/base/operations.py @@ -9,6 +9,7 @@ from django.conf import settings from django.db import NotSupportedError, transaction from django.db.models.expressions import Col from django.utils import timezone +from django.utils.duration import duration_microseconds from django.utils.encoding import force_str @@ -564,6 +565,16 @@ class BaseDatabaseOperations: return None return str(value) + def adapt_durationfield_value(self, value): + """ + Transform a timedelta value into an object compatible with what is + expected by the backend driver for duration columns (by default, + an integer of microseconds). + """ + if value is None: + return None + return duration_microseconds(value) + def adapt_timefield_value(self, value): """ Transform a time value to an object compatible with what is expected diff --git a/django/db/backends/oracle/operations.py b/django/db/backends/oracle/operations.py index bc152c4e6e..e5da5928f9 100644 --- a/django/db/backends/oracle/operations.py +++ b/django/db/backends/oracle/operations.py @@ -608,6 +608,9 @@ END; return Oracle_datetime.from_datetime(value) + def adapt_durationfield_value(self, value): + return value + def adapt_timefield_value(self, value): if value is None: return None diff --git a/django/db/backends/postgresql/operations.py b/django/db/backends/postgresql/operations.py index 7cd868d789..91456e3daf 100644 --- a/django/db/backends/postgresql/operations.py +++ b/django/db/backends/postgresql/operations.py @@ -330,6 +330,9 @@ class DatabaseOperations(BaseDatabaseOperations): def adapt_datetimefield_value(self, value): return value + def adapt_durationfield_value(self, value): + return value + def adapt_timefield_value(self, value): return value diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index ee7a30cc30..f12ae97968 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -30,7 +30,7 @@ from django.utils.dateparse import ( parse_duration, parse_time, ) -from django.utils.duration import duration_microseconds, duration_string +from django.utils.duration import duration_string from django.utils.functional import Promise, cached_property from django.utils.ipv6 import MAX_IPV6_ADDRESS_LENGTH, clean_ipv6_address from django.utils.text import capfirst @@ -1890,11 +1890,7 @@ class DurationField(Field): ) def get_db_prep_value(self, value, connection, prepared=False): - if connection.features.has_native_duration_field: - return value - if value is None: - return None - return duration_microseconds(value) + return connection.ops.adapt_durationfield_value(value) def get_db_converters(self, connection): converters = [] diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index be05350d94..edfdacf6b7 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -243,7 +243,9 @@ Database backend API This section describes changes that may be needed in third-party database backends. -* ... +* The ``DatabaseOperations.adapt_durationfield_value()`` hook is added. If the + database has native support for ``DurationField``, override this method to + simply return the value. Miscellaneous ------------- From 44addbf4e7e0cc4211c4c3418469800cd275c886 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Wed, 24 Sep 2025 09:47:47 +0200 Subject: [PATCH 013/116] Refs #35859 -- Mentioned tasks in the docs index. --- docs/index.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.txt b/docs/index.txt index b25a345a96..9ff54c389f 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -295,6 +295,7 @@ applications: :doc:`API Reference ` * :doc:`Caching ` * :doc:`Logging ` +* :doc:`Tasks framework ` * :doc:`Sending emails ` * :doc:`Syndication feeds (RSS/Atom) ` * :doc:`Pagination ` From 2e870c60718888067249f7f2c2e40e8eac3d13bc Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 23 Sep 2025 17:28:38 +0100 Subject: [PATCH 014/116] Refs #36163 -- Removed duplicated release note paragraph. --- docs/releases/6.0.txt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index e1cdc7fc67..7fb4da9d19 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -492,7 +492,7 @@ Positional arguments in ``django.core.mail`` APIs :mod:`django.core.mail` APIs now require keyword arguments for less commonly used parameters. Using positional arguments for these now emits a deprecation -warning and will raise a :exc:`TypeError` when the deprecation period ends. +warning and will raise a :exc:`TypeError` when the deprecation period ends: * All *optional* parameters (``fail_silently`` and later) must be passed as keyword arguments to :func:`get_connection`, :func:`mail_admins`, @@ -503,10 +503,6 @@ warning and will raise a :exc:`TypeError` when the deprecation period ends. the first four (``subject``, ``body``, ``from_email``, and ``to``), which may still be passed either as positional or keyword arguments. -* :mod:`django.core.mail` APIs now require keyword arguments for less commonly - used parameters. Using positional arguments for these now emits a deprecation - warning and will raise a :exc:`TypeError` when the deprecation period ends: - Miscellaneous ------------- From f2e02198671a4c099744efdc166f98525cbae4c1 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 23 Sep 2025 17:28:38 +0100 Subject: [PATCH 015/116] Refs #36163 -- Removed currentmodule directive from 6.0 release notes. --- docs/releases/6.0.txt | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index 7fb4da9d19..56e4bf2315 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -488,20 +488,18 @@ Features deprecated in 6.0 Positional arguments in ``django.core.mail`` APIs ------------------------------------------------- -.. currentmodule:: django.core.mail - :mod:`django.core.mail` APIs now require keyword arguments for less commonly used parameters. Using positional arguments for these now emits a deprecation warning and will raise a :exc:`TypeError` when the deprecation period ends: * All *optional* parameters (``fail_silently`` and later) must be passed as - keyword arguments to :func:`get_connection`, :func:`mail_admins`, - :func:`mail_managers`, :func:`send_mail`, and :func:`send_mass_mail`. + keyword arguments to :func:`.get_connection`, :func:`.mail_admins`, + :func:`.mail_managers`, :func:`.send_mail`, and :func:`.send_mass_mail`. * All parameters must be passed as keyword arguments when creating an - :class:`EmailMessage` or :class:`EmailMultiAlternatives` instance, except for - the first four (``subject``, ``body``, ``from_email``, and ``to``), which may - still be passed either as positional or keyword arguments. + :class:`.EmailMessage` or :class:`.EmailMultiAlternatives` instance, except + for the first four (``subject``, ``body``, ``from_email``, and ``to``), which + may still be passed either as positional or keyword arguments. Miscellaneous ------------- From 00174507f8a91e9577ae233c58af561b379f2695 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Fri, 12 Sep 2025 19:28:20 +0200 Subject: [PATCH 016/116] Added stub release notes and release date for 5.2.7, 5.1.13, and 4.2.25. --- docs/releases/4.2.25.txt | 10 ++++++++++ docs/releases/5.1.13.txt | 10 ++++++++++ docs/releases/5.2.7.txt | 7 ++++--- docs/releases/index.txt | 2 ++ 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 docs/releases/4.2.25.txt create mode 100644 docs/releases/5.1.13.txt diff --git a/docs/releases/4.2.25.txt b/docs/releases/4.2.25.txt new file mode 100644 index 0000000000..69f238c3c1 --- /dev/null +++ b/docs/releases/4.2.25.txt @@ -0,0 +1,10 @@ +=========================== +Django 4.2.25 release notes +=========================== + +*October 1, 2025* + +Django 4.2.25 fixes one security issue with severity "high" and one security +issue with severity "low" in 4.2.24. + +... diff --git a/docs/releases/5.1.13.txt b/docs/releases/5.1.13.txt new file mode 100644 index 0000000000..a181694be2 --- /dev/null +++ b/docs/releases/5.1.13.txt @@ -0,0 +1,10 @@ +=========================== +Django 5.1.13 release notes +=========================== + +*October 1, 2025* + +Django 5.1.13 fixes one security issue with severity "high" and one security +issue with severity "low" in 5.1.12. + +... diff --git a/docs/releases/5.2.7.txt b/docs/releases/5.2.7.txt index cab28ad2a4..90d620a408 100644 --- a/docs/releases/5.2.7.txt +++ b/docs/releases/5.2.7.txt @@ -2,10 +2,11 @@ Django 5.2.7 release notes ========================== -*Expected October 1, 2025* +*October 1, 2025* -Django 5.2.7 fixes several bugs in 5.2.6. Also, the latest string translations -from Transifex are incorporated. +Django 5.2.7 fixes one security issue with severity "high", one security issue +with severity "low", and several bugs in 5.2.6. Also, the latest string +translations from Transifex are incorporated. Bugfixes ======== diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 1a591035e2..8a2d5c13a4 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -53,6 +53,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 5.1.13 5.1.12 5.1.11 5.1.10 @@ -94,6 +95,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 4.2.25 4.2.24 4.2.23 4.2.22 From 5cbd96003ce13621ac247f8b09c1b625daf9c7c8 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 23 Sep 2025 17:31:23 +0100 Subject: [PATCH 017/116] Removed Git attribute merge=union for release notes. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I added this back in 3222fc79431c0866aa65b2a83fbbffd2c3034d08 to try and avoid merge conflicts from concurrent edits to release notes in different branches. However, in my recent experience, it has caused more problems than it solves. I have found that when rebasing a branch that modifies a release note, it can merge sections without an intermediate blank line, leading to broken reST syntax. Example spotted in code review: https://github.com/django/django/pull/17554#discussion_r2311296513 . I think it’s better we remove this configuration and deal with merge conflicts deliberately. --- .gitattributes | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 2c9770c278..1a5fcdfd81 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,6 +4,5 @@ *js text eol=lf tests/staticfiles_tests/apps/test/static/test/*txt text eol=lf tests/staticfiles_tests/project/documents/test/*txt text eol=lf -docs/releases/*.txt merge=union # Make GitHub syntax-highlight .html files as Django templates *.html linguist-language=django From 68aae8878ff90dd787db55ecc44ee712525ccdc6 Mon Sep 17 00:00:00 2001 From: SaJH Date: Wed, 24 Sep 2025 00:11:31 +0900 Subject: [PATCH 018/116] Fixed #36434 -- Preserved unbuffered stdio (-u) in autoreloader child. Signed-off-by: SaJH --- django/utils/autoreload.py | 13 ++++++++ tests/utils_tests/test_autoreload.py | 47 ++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/django/utils/autoreload.py b/django/utils/autoreload.py index c6716215f5..99812979d7 100644 --- a/django/utils/autoreload.py +++ b/django/utils/autoreload.py @@ -268,6 +268,19 @@ def trigger_reload(filename): def restart_with_reloader(): new_environ = {**os.environ, DJANGO_AUTORELOAD_ENV: "true"} + orig = getattr(sys, "orig_argv", ()) + if any( + (arg == "-u") + or ( + arg.startswith("-") + and not arg.startswith(("--", "-X", "-W")) + and len(arg) > 2 + and arg[1:].isalpha() + and "u" in arg + ) + for arg in orig[1:] + ): + new_environ.setdefault("PYTHONUNBUFFERED", "1") args = get_child_arguments() while True: p = subprocess.run(args, env=new_environ, close_fds=False) diff --git a/tests/utils_tests/test_autoreload.py b/tests/utils_tests/test_autoreload.py index 83f3e3898f..c9e6443c6f 100644 --- a/tests/utils_tests/test_autoreload.py +++ b/tests/utils_tests/test_autoreload.py @@ -535,6 +535,53 @@ class RestartWithReloaderTests(SimpleTestCase): [self.executable, "-Wall", "-m", "django"] + argv[1:], ) + def test_propagates_unbuffered_from_parent(self): + for args in ("-u", "-Iuv"): + with self.subTest(args=args): + with mock.patch.dict(os.environ, {}, clear=True): + with tempfile.TemporaryDirectory() as d: + script = Path(d) / "manage.py" + script.touch() + mock_call = self.patch_autoreload([str(script), "runserver"]) + with ( + mock.patch("__main__.__spec__", None), + mock.patch.object( + autoreload.sys, + "orig_argv", + [self.executable, args, str(script), "runserver"], + ), + ): + autoreload.restart_with_reloader() + env = mock_call.call_args.kwargs["env"] + self.assertEqual(env.get("PYTHONUNBUFFERED"), "1") + + def test_does_not_propagate_unbuffered_from_parent(self): + for args in ( + "-Xdev", + "-Xfaulthandler", + "--user", + "-Wall", + "-Wdefault", + "-Wignore::UserWarning", + ): + with self.subTest(args=args): + with mock.patch.dict(os.environ, {}, clear=True): + with tempfile.TemporaryDirectory() as d: + script = Path(d) / "manage.py" + script.touch() + mock_call = self.patch_autoreload([str(script), "runserver"]) + with ( + mock.patch("__main__.__spec__", None), + mock.patch.object( + autoreload.sys, + "orig_argv", + [self.executable, args, str(script), "runserver"], + ), + ): + autoreload.restart_with_reloader() + env = mock_call.call_args.kwargs["env"] + self.assertIsNone(env.get("PYTHONUNBUFFERED")) + class ReloaderTests(SimpleTestCase): RELOADER_CLS = None From 1820d35b17f0a95f4ce888971b9ca0c7a3697c83 Mon Sep 17 00:00:00 2001 From: John Parton Date: Sun, 18 Aug 2024 23:12:14 -0500 Subject: [PATCH 019/116] Fixed #36605 -- Added support for QuerySet.in_bulk() after .values() or .values_list(). co-authored-by: Adam Johnson co-authored-by: Simon Charette --- django/db/models/query.py | 66 ++++++++- docs/ref/models/querysets.txt | 5 + docs/releases/6.1.txt | 3 +- tests/composite_pk/tests.py | 61 +++++++++ tests/lookup/tests.py | 246 +++++++++++++++++++++++++++++++++- 5 files changed, 367 insertions(+), 14 deletions(-) diff --git a/django/db/models/query.py b/django/db/models/query.py index 721bf33e57..4cccb383fd 100644 --- a/django/db/models/query.py +++ b/django/db/models/query.py @@ -1166,8 +1166,6 @@ class QuerySet(AltersData): """ if self.query.is_sliced: raise TypeError("Cannot use 'limit' or 'offset' with in_bulk().") - if not issubclass(self._iterable_class, ModelIterable): - raise TypeError("in_bulk() cannot be used with values() or values_list().") opts = self.model._meta unique_fields = [ constraint.fields[0] @@ -1184,6 +1182,59 @@ class QuerySet(AltersData): "in_bulk()'s field_name must be a unique field but %r isn't." % field_name ) + + qs = self + + def get_obj(obj): + return obj + + if issubclass(self._iterable_class, ModelIterable): + # Raise an AttributeError if field_name is deferred. + get_key = operator.attrgetter(field_name) + + elif issubclass(self._iterable_class, ValuesIterable): + if field_name not in self.query.values_select: + qs = qs.values(field_name, *self.query.values_select) + + def get_obj(obj): # noqa: F811 + # We can safely mutate the dictionaries returned by + # ValuesIterable here, since they are limited to the scope + # of this function, and get_key runs before get_obj. + del obj[field_name] + return obj + + get_key = operator.itemgetter(field_name) + + elif issubclass(self._iterable_class, ValuesListIterable): + try: + field_index = self.query.values_select.index(field_name) + except ValueError: + # field_name is missing from values_select, so add it. + field_index = 0 + if issubclass(self._iterable_class, NamedValuesListIterable): + kwargs = {"named": True} + else: + kwargs = {} + get_obj = operator.itemgetter(slice(1, None)) + qs = qs.values_list(field_name, *self.query.values_select, **kwargs) + + get_key = operator.itemgetter(field_index) + + elif issubclass(self._iterable_class, FlatValuesListIterable): + if self.query.values_select == (field_name,): + # Mapping field_name to itself. + get_key = get_obj + else: + # Transform it back into a non-flat values_list(). + qs = qs.values_list(field_name, *self.query.values_select) + get_key = operator.itemgetter(0) + get_obj = operator.itemgetter(1) + + else: + raise TypeError( + f"in_bulk() cannot be used with {self._iterable_class.__name__}." + ) + if id_list is not None: if not id_list: return {} @@ -1193,15 +1244,16 @@ class QuerySet(AltersData): # If the database has a limit on the number of query parameters # (e.g. SQLite), retrieve objects in batches if necessary. if batch_size and batch_size < len(id_list): - qs = () + results = () for offset in range(0, len(id_list), batch_size): batch = id_list[offset : offset + batch_size] - qs += tuple(self.filter(**{filter_key: batch})) + results += tuple(qs.filter(**{filter_key: batch})) + qs = results else: - qs = self.filter(**{filter_key: id_list}) + qs = qs.filter(**{filter_key: id_list}) else: - qs = self._chain() - return {getattr(obj, field_name): obj for obj in qs} + qs = qs._chain() + return {get_key(obj): get_obj(obj) for obj in qs} async def ain_bulk(self, id_list=None, *, field_name="pk"): return await sync_to_async(self.in_bulk)( diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 5fb6cb33b3..4be1759af2 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -2588,6 +2588,11 @@ Example: If you pass ``in_bulk()`` an empty list, you'll get an empty dictionary. +.. versionchanged:: 6.1 + + Support for chaining ``in_bulk()`` after :meth:`values` or + :meth:`values_list` was added. + ``iterator()`` ~~~~~~~~~~~~~~ diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index edfdacf6b7..56a222f3e3 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -175,7 +175,8 @@ Migrations Models ~~~~~~ -* ... +* :meth:`.QuerySet.in_bulk` now supports chaining after + :meth:`.QuerySet.values` and :meth:`.QuerySet.values_list`. Pagination ~~~~~~~~~~ diff --git a/tests/composite_pk/tests.py b/tests/composite_pk/tests.py index c4a8e6ca8c..3001847455 100644 --- a/tests/composite_pk/tests.py +++ b/tests/composite_pk/tests.py @@ -167,6 +167,67 @@ class CompositePKTests(TestCase): comment_dict = Comment.objects.in_bulk(id_list=id_list) self.assertQuerySetEqual(comment_dict, id_list) + def test_in_bulk_values(self): + result = Comment.objects.values().in_bulk([self.comment.pk]) + self.assertEqual( + result, + { + self.comment.pk: { + "tenant_id": self.comment.tenant_id, + "id": self.comment.id, + "user_id": self.comment.user_id, + "text": self.comment.text, + "integer": self.comment.integer, + } + }, + ) + + def test_in_bulk_values_field(self): + result = Comment.objects.values("text").in_bulk([self.comment.pk]) + self.assertEqual( + result, + {self.comment.pk: {"text": self.comment.text}}, + ) + + def test_in_bulk_values_fields(self): + result = Comment.objects.values("pk", "text").in_bulk([self.comment.pk]) + self.assertEqual( + result, + {self.comment.pk: {"pk": self.comment.pk, "text": self.comment.text}}, + ) + + def test_in_bulk_values_list(self): + result = Comment.objects.values_list("text").in_bulk([self.comment.pk]) + self.assertEqual(result, {self.comment.pk: (self.comment.text,)}) + + def test_in_bulk_values_list_multiple_fields(self): + result = Comment.objects.values_list("pk", "text").in_bulk([self.comment.pk]) + self.assertEqual( + result, {self.comment.pk: (self.comment.pk, self.comment.text)} + ) + + def test_in_bulk_values_list_fields_are_pk(self): + result = Comment.objects.values_list("tenant", "id").in_bulk([self.comment.pk]) + self.assertEqual( + result, {self.comment.pk: (self.comment.tenant_id, self.comment.id)} + ) + + def test_in_bulk_values_list_flat(self): + result = Comment.objects.values_list("text", flat=True).in_bulk( + [self.comment.pk] + ) + self.assertEqual(result, {self.comment.pk: self.comment.text}) + + def test_in_bulk_values_list_flat_pk(self): + result = Comment.objects.values_list("pk", flat=True).in_bulk([self.comment.pk]) + self.assertEqual(result, {self.comment.pk: self.comment.pk}) + + def test_in_bulk_values_list_flat_tenant(self): + result = Comment.objects.values_list("tenant", flat=True).in_bulk( + [self.comment.pk] + ) + self.assertEqual(result, {self.comment.pk: self.tenant.id}) + def test_iterator(self): """ Test the .iterator() method of composite_pk models. diff --git a/tests/lookup/tests.py b/tests/lookup/tests.py index e013666fc4..f6f73e9fac 100644 --- a/tests/lookup/tests.py +++ b/tests/lookup/tests.py @@ -317,12 +317,246 @@ class LookupTests(TestCase): with self.assertRaisesMessage(TypeError, msg): Article.objects.all()[0:5].in_bulk([self.a1.id, self.a2.id]) - def test_in_bulk_not_model_iterable(self): - msg = "in_bulk() cannot be used with values() or values_list()." - with self.assertRaisesMessage(TypeError, msg): - Author.objects.values().in_bulk() - with self.assertRaisesMessage(TypeError, msg): - Author.objects.values_list().in_bulk() + def test_in_bulk_values_empty(self): + arts = Article.objects.values().in_bulk([]) + self.assertEqual(arts, {}) + + def test_in_bulk_values_all(self): + Article.objects.exclude(pk__in=[self.a1.pk, self.a2.pk]).delete() + arts = Article.objects.values().in_bulk() + self.assertEqual( + arts, + { + self.a1.pk: { + "id": self.a1.pk, + "author_id": self.au1.pk, + "headline": "Article 1", + "pub_date": self.a1.pub_date, + "slug": "a1", + }, + self.a2.pk: { + "id": self.a2.pk, + "author_id": self.au1.pk, + "headline": "Article 2", + "pub_date": self.a2.pub_date, + "slug": "a2", + }, + }, + ) + + def test_in_bulk_values_pks(self): + arts = Article.objects.values().in_bulk([self.a1.pk]) + self.assertEqual( + arts, + { + self.a1.pk: { + "id": self.a1.pk, + "author_id": self.au1.pk, + "headline": "Article 1", + "pub_date": self.a1.pub_date, + "slug": "a1", + } + }, + ) + + def test_in_bulk_values_fields(self): + arts = Article.objects.values("headline").in_bulk([self.a1.pk]) + self.assertEqual( + arts, + {self.a1.pk: {"headline": "Article 1"}}, + ) + + def test_in_bulk_values_fields_including_pk(self): + arts = Article.objects.values("pk", "headline").in_bulk([self.a1.pk]) + self.assertEqual( + arts, + {self.a1.pk: {"pk": self.a1.pk, "headline": "Article 1"}}, + ) + + def test_in_bulk_values_fields_pk(self): + arts = Article.objects.values("pk").in_bulk([self.a1.pk]) + self.assertEqual( + arts, + {self.a1.pk: {"pk": self.a1.pk}}, + ) + + def test_in_bulk_values_fields_id(self): + arts = Article.objects.values("id").in_bulk([self.a1.pk]) + self.assertEqual( + arts, + {self.a1.pk: {"id": self.a1.pk}}, + ) + + def test_in_bulk_values_alternative_field_name(self): + arts = Article.objects.values("headline").in_bulk( + [self.a1.slug], field_name="slug" + ) + self.assertEqual( + arts, + {self.a1.slug: {"headline": "Article 1"}}, + ) + + def test_in_bulk_values_list_empty(self): + arts = Article.objects.values_list().in_bulk([]) + self.assertEqual(arts, {}) + + def test_in_bulk_values_list_all(self): + Article.objects.exclude(pk__in=[self.a1.pk, self.a2.pk]).delete() + arts = Article.objects.values_list().in_bulk() + self.assertEqual( + arts, + { + self.a1.pk: ( + self.a1.pk, + "Article 1", + self.a1.pub_date, + self.au1.pk, + "a1", + ), + self.a2.pk: ( + self.a2.pk, + "Article 2", + self.a2.pub_date, + self.au1.pk, + "a2", + ), + }, + ) + + def test_in_bulk_values_list_fields(self): + arts = Article.objects.values_list("headline").in_bulk([self.a1.pk, self.a2.pk]) + self.assertEqual( + arts, + { + self.a1.pk: ("Article 1",), + self.a2.pk: ("Article 2",), + }, + ) + + def test_in_bulk_values_list_fields_including_pk(self): + arts = Article.objects.values_list("pk", "headline").in_bulk( + [self.a1.pk, self.a2.pk] + ) + self.assertEqual( + arts, + { + self.a1.pk: (self.a1.pk, "Article 1"), + self.a2.pk: (self.a2.pk, "Article 2"), + }, + ) + + def test_in_bulk_values_list_fields_pk(self): + arts = Article.objects.values_list("pk").in_bulk([self.a1.pk, self.a2.pk]) + self.assertEqual( + arts, + { + self.a1.pk: (self.a1.pk,), + self.a2.pk: (self.a2.pk,), + }, + ) + + def test_in_bulk_values_list_fields_id(self): + arts = Article.objects.values_list("id").in_bulk([self.a1.pk, self.a2.pk]) + self.assertEqual( + arts, + { + self.a1.pk: (self.a1.pk,), + self.a2.pk: (self.a2.pk,), + }, + ) + + def test_in_bulk_values_list_named(self): + arts = Article.objects.values_list(named=True).in_bulk([self.a1.pk, self.a2.pk]) + self.assertIsInstance(arts, dict) + self.assertEqual(len(arts), 2) + arts1 = arts[self.a1.pk] + self.assertEqual( + arts1._fields, ("pk", "id", "headline", "pub_date", "author_id", "slug") + ) + self.assertEqual(arts1.pk, self.a1.pk) + self.assertEqual(arts1.headline, "Article 1") + self.assertEqual(arts1.pub_date, self.a1.pub_date) + self.assertEqual(arts1.author_id, self.au1.pk) + self.assertEqual(arts1.slug, "a1") + + def test_in_bulk_values_list_named_fields(self): + arts = Article.objects.values_list("pk", "headline", named=True).in_bulk( + [self.a1.pk, self.a2.pk] + ) + self.assertIsInstance(arts, dict) + self.assertEqual(len(arts), 2) + arts1 = arts[self.a1.pk] + self.assertEqual(arts1._fields, ("pk", "headline")) + self.assertEqual(arts1.pk, self.a1.pk) + self.assertEqual(arts1.headline, "Article 1") + + def test_in_bulk_values_list_named_fields_alternative_field(self): + arts = Article.objects.values_list("headline", named=True).in_bulk( + [self.a1.slug, self.a2.slug], field_name="slug" + ) + self.assertEqual(len(arts), 2) + arts1 = arts[self.a1.slug] + self.assertEqual(arts1._fields, ("slug", "headline")) + self.assertEqual(arts1.slug, "a1") + self.assertEqual(arts1.headline, "Article 1") + + def test_in_bulk_values_list_flat_empty(self): + arts = Article.objects.values_list(flat=True).in_bulk([]) + self.assertEqual(arts, {}) + + def test_in_bulk_values_list_flat_all(self): + Article.objects.exclude(pk__in=[self.a1.pk, self.a2.pk]).delete() + arts = Article.objects.values_list(flat=True).in_bulk() + self.assertEqual( + arts, + { + self.a1.pk: self.a1.pk, + self.a2.pk: self.a2.pk, + }, + ) + + def test_in_bulk_values_list_flat_pks(self): + arts = Article.objects.values_list(flat=True).in_bulk([self.a1.pk, self.a2.pk]) + self.assertEqual( + arts, + { + self.a1.pk: self.a1.pk, + self.a2.pk: self.a2.pk, + }, + ) + + def test_in_bulk_values_list_flat_field(self): + arts = Article.objects.values_list("headline", flat=True).in_bulk( + [self.a1.pk, self.a2.pk] + ) + self.assertEqual( + arts, + {self.a1.pk: "Article 1", self.a2.pk: "Article 2"}, + ) + + def test_in_bulk_values_list_flat_field_pk(self): + arts = Article.objects.values_list("pk", flat=True).in_bulk( + [self.a1.pk, self.a2.pk] + ) + self.assertEqual( + arts, + { + self.a1.pk: self.a1.pk, + self.a2.pk: self.a2.pk, + }, + ) + + def test_in_bulk_values_list_flat_field_id(self): + arts = Article.objects.values_list("id", flat=True).in_bulk( + [self.a1.pk, self.a2.pk] + ) + self.assertEqual( + arts, + { + self.a1.pk: self.a1.pk, + self.a2.pk: self.a2.pk, + }, + ) def test_values(self): # values() returns a list of dictionaries instead of object instances, From 46bd92274c57fd6f138c067562696092732cec59 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Thu, 11 Sep 2025 11:49:54 +0100 Subject: [PATCH 020/116] Refs #36605 -- Optimized QuerySet.in_bulk() for the empty id_list case. Now that the setup is a bit more expensive, it makes sense to return earlier for the empty case. --- django/db/models/query.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/django/db/models/query.py b/django/db/models/query.py index 4cccb383fd..8edae41e5f 100644 --- a/django/db/models/query.py +++ b/django/db/models/query.py @@ -1166,6 +1166,8 @@ class QuerySet(AltersData): """ if self.query.is_sliced: raise TypeError("Cannot use 'limit' or 'offset' with in_bulk().") + if id_list is not None and not id_list: + return {} opts = self.model._meta unique_fields = [ constraint.fields[0] @@ -1236,8 +1238,6 @@ class QuerySet(AltersData): ) if id_list is not None: - if not id_list: - return {} filter_key = "{}__in".format(field_name) id_list = tuple(id_list) batch_size = connections[self.db].ops.bulk_batch_size([opts.pk], id_list) From 7894776bc99e42f20e0919fc9027e6db542957d5 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Thu, 18 Sep 2025 12:53:24 -0400 Subject: [PATCH 021/116] Refs #28526 -- Provided URLResolver namespace in technical 404 template. This avoids looking up the nonexistent "name" attribute on URLResolver, which logs verbosely. --- django/views/debug.py | 19 +++++++++++++++++-- django/views/templates/technical_404.html | 8 ++++---- tests/view_tests/tests/test_debug.py | 10 ++++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/django/views/debug.py b/django/views/debug.py index 5a1b4aee91..f7e141d1c6 100644 --- a/django/views/debug.py +++ b/django/views/debug.py @@ -11,7 +11,7 @@ from django.conf import settings from django.http import Http404, HttpResponse, HttpResponseNotFound from django.template import Context, Engine, TemplateDoesNotExist from django.template.defaultfilters import pprint -from django.urls import resolve +from django.urls import URLResolver, resolve from django.utils import timezone from django.utils.datastructures import MultiValueDict from django.utils.encoding import force_str @@ -635,6 +635,20 @@ def technical_404_response(request, exception): ): return default_urlconf(request) + patterns_with_debug_info = [] + for urlpattern in tried or (): + patterns = [] + for inner_pattern in urlpattern: + wrapper = {"tried": inner_pattern} + if isinstance(inner_pattern, URLResolver): + wrapper["debug_key"] = "namespace" + wrapper["debug_val"] = inner_pattern.namespace + else: + wrapper["debug_key"] = "name" + wrapper["debug_val"] = inner_pattern.name + patterns.append(wrapper) + patterns_with_debug_info.append(patterns) + urlconf = getattr(request, "urlconf", settings.ROOT_URLCONF) if isinstance(urlconf, types.ModuleType): urlconf = urlconf.__name__ @@ -647,7 +661,8 @@ def technical_404_response(request, exception): "urlconf": urlconf, "root_urlconf": settings.ROOT_URLCONF, "request_path": error_url, - "urlpatterns": tried, + "urlpatterns": tried, # Unused, left for compatibility. + "urlpatterns_debug": patterns_with_debug_info, "resolved": resolved, "reason": str(exception), "request": request, diff --git a/django/views/templates/technical_404.html b/django/views/templates/technical_404.html index f8d4e92c08..73abb22af4 100644 --- a/django/views/templates/technical_404.html +++ b/django/views/templates/technical_404.html @@ -46,18 +46,18 @@
- {% if urlpatterns %} + {% if urlpatterns_debug %}

Using the URLconf defined in {{ urlconf }}, Django tried these URL patterns, in this order:

    - {% for pattern in urlpatterns %} + {% for pattern in urlpatterns_debug %}
  1. {% for pat in pattern %} - {{ pat.pattern }} - {% if forloop.last and pat.name %}[name='{{ pat.name }}']{% endif %} + {{ pat.tried.pattern }} + {% if forloop.last and pat.debug_val %}[{{ pat.debug_key }}='{{ pat.debug_val }}']{% endif %} {% endfor %}
  2. diff --git a/tests/view_tests/tests/test_debug.py b/tests/view_tests/tests/test_debug.py index 8e36ab7eb1..439faff84e 100644 --- a/tests/view_tests/tests/test_debug.py +++ b/tests/view_tests/tests/test_debug.py @@ -423,6 +423,16 @@ class DebugViewTests(SimpleTestCase): response, "

    The install worked successfully! Congratulations!

    " ) + @override_settings(ROOT_URLCONF="view_tests.default_urls") + def test_default_urlconf_technical_404(self): + response = self.client.get("/favicon.ico") + self.assertContains( + response, + "\nadmin/\n[namespace='admin']\n", + status_code=404, + html=True, + ) + @override_settings(ROOT_URLCONF="view_tests.regression_21530_urls") def test_regression_21530(self): """ From 1cb76b90e844308ae21d24115311bc354efe56e6 Mon Sep 17 00:00:00 2001 From: Romain DA COSTA VIEIRA Date: Wed, 24 Sep 2025 14:55:54 +0200 Subject: [PATCH 022/116] Fixed #36142 -- Made Http404 messages in *_or_404() shortcuts translatable. --- django/shortcuts.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/django/shortcuts.py b/django/shortcuts.py index 6274631dba..4eeb39121e 100644 --- a/django/shortcuts.py +++ b/django/shortcuts.py @@ -13,6 +13,7 @@ from django.http import ( from django.template import loader from django.urls import NoReverseMatch, reverse from django.utils.functional import Promise +from django.utils.translation import gettext as _ def render( @@ -90,7 +91,10 @@ def get_object_or_404(klass, *args, **kwargs): return queryset.get(*args, **kwargs) except queryset.model.DoesNotExist: raise Http404( - "No %s matches the given query." % queryset.model._meta.object_name + # Translators: %s is the name of a model, e.g. "No City matches the + # given query." + _("No %s matches the given query.") + % queryset.model._meta.object_name ) @@ -108,7 +112,9 @@ async def aget_object_or_404(klass, *args, **kwargs): try: return await queryset.aget(*args, **kwargs) except queryset.model.DoesNotExist: - raise Http404(f"No {queryset.model._meta.object_name} matches the given query.") + raise Http404( + _("No %s matches the given query.") % queryset.model._meta.object_name + ) def get_list_or_404(klass, *args, **kwargs): @@ -131,7 +137,7 @@ def get_list_or_404(klass, *args, **kwargs): obj_list = list(queryset.filter(*args, **kwargs)) if not obj_list: raise Http404( - "No %s matches the given query." % queryset.model._meta.object_name + _("No %s matches the given query.") % queryset.model._meta.object_name ) return obj_list @@ -149,7 +155,9 @@ async def aget_list_or_404(klass, *args, **kwargs): ) obj_list = [obj async for obj in queryset.filter(*args, **kwargs)] if not obj_list: - raise Http404(f"No {queryset.model._meta.object_name} matches the given query.") + raise Http404( + _("No %s matches the given query.") % queryset.model._meta.object_name + ) return obj_list From be581ff473e8ade6365975db2df602f295a4cb4b Mon Sep 17 00:00:00 2001 From: Shubham Singh Date: Fri, 12 Sep 2025 14:32:35 -0500 Subject: [PATCH 023/116] Fixed #36491 -- Fixed crash in ParallelTestRunner with --buffer. Thanks Javier Buzzi and Adam Johnson for reviews. Co-authored-by: Simon Charette --- django/test/runner.py | 3 +++ tests/test_runner/test_parallel.py | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/django/test/runner.py b/django/test/runner.py index 8902dea3e0..25089a6db1 100644 --- a/django/test/runner.py +++ b/django/test/runner.py @@ -566,6 +566,9 @@ class ParallelTestSuite(unittest.TestSuite): (self.runner_class, index, subsuite, self.failfast, self.buffer) for index, subsuite in enumerate(self.subsuites) ] + # Don't buffer in the main process to avoid error propagation issues. + result.buffer = False + test_results = pool.imap_unordered(self.run_subsuite.__func__, args) while True: diff --git a/tests/test_runner/test_parallel.py b/tests/test_runner/test_parallel.py index f344f1a2db..d4558018b0 100644 --- a/tests/test_runner/test_parallel.py +++ b/tests/test_runner/test_parallel.py @@ -282,3 +282,27 @@ class ParallelTestSuiteTest(SimpleTestCase): self.assertEqual(len(result.errors), 0) self.assertEqual(len(result.failures), 0) + + def test_buffer_mode_reports_setupclass_failure(self): + test = SampleErrorTest("dummy_test") + remote_result = RemoteTestResult() + suite = TestSuite([test]) + suite.run(remote_result) + + pts = ParallelTestSuite([suite], processes=2, buffer=True) + pts.serialized_aliases = set() + test_result = TestResult() + test_result.buffer = True + + with unittest.mock.patch("multiprocessing.Pool") as mock_pool: + + def fake_next(*args, **kwargs): + test_result.shouldStop = True + return (0, remote_result.events) + + mock_pool.return_value.imap_unordered.return_value = unittest.mock.Mock( + next=fake_next + ) + pts.run(test_result) + + self.assertIn("ValueError: woops", test_result.errors[0][1]) From daba609a9bdc7a97bcf327c7ba0a5f7b3540b46e Mon Sep 17 00:00:00 2001 From: Samriddha9619 Date: Tue, 23 Sep 2025 01:27:30 +0530 Subject: [PATCH 024/116] Fixed #35877, Refs #36128 -- Documented unique constraint when migrating a m2m field to use a through model. --- docs/howto/writing-migrations.txt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/howto/writing-migrations.txt b/docs/howto/writing-migrations.txt index d092aec6e8..3a818a0fda 100644 --- a/docs/howto/writing-migrations.txt +++ b/docs/howto/writing-migrations.txt @@ -336,7 +336,7 @@ model, the default migration will delete the existing table and create a new one, losing the existing relations. To avoid this, you can use :class:`.SeparateDatabaseAndState` to rename the existing table to the new table name while telling the migration autodetector that the new model has -been created. You can check the existing table name through +been created. You can check the existing table name and constraint name through :djadmin:`sqlmigrate` or :djadmin:`dbshell`. You can check the new table name with the through model's ``_meta.db_table`` property. Your new ``through`` model should use the same names for the ``ForeignKey``\s as Django did. Also if @@ -394,6 +394,14 @@ For example, if we had a ``Book`` model with a ``ManyToManyField`` linking to ), ), ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=["author", "book"], + name="unique_author_book", + ) + ], + }, ), migrations.AlterField( model_name="book", From e8190b370e508648b0f0ee9b86876f97d3997e14 Mon Sep 17 00:00:00 2001 From: arsalan64 <68010697+arsalan64@users.noreply.github.com> Date: Sat, 27 Sep 2025 19:12:34 +0200 Subject: [PATCH 025/116] Fixed #36277 -- Fixed DatabaseFeatures.supports_virtual_generated_columns on PostgreSQL 18+. --- django/db/backends/postgresql/features.py | 8 +++++++- docs/ref/models/fields.txt | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/django/db/backends/postgresql/features.py b/django/db/backends/postgresql/features.py index 5f63b6c713..23c31c0929 100644 --- a/django/db/backends/postgresql/features.py +++ b/django/db/backends/postgresql/features.py @@ -67,7 +67,6 @@ class DatabaseFeatures(BaseDatabaseFeatures): supports_update_conflicts_with_target = True supports_covering_indexes = True supports_stored_generated_columns = True - supports_virtual_generated_columns = False can_rename_index = True test_collations = { "deterministic": "C", @@ -168,9 +167,16 @@ class DatabaseFeatures(BaseDatabaseFeatures): def is_postgresql_17(self): return self.connection.pg_version >= 170000 + @cached_property + def is_postgresql_18(self): + return self.connection.pg_version >= 180000 + supports_unlimited_charfield = True supports_nulls_distinct_unique_constraints = property( operator.attrgetter("is_postgresql_15") ) supports_any_value = property(operator.attrgetter("is_postgresql_16")) + supports_virtual_generated_columns = property( + operator.attrgetter("is_postgresql_18") + ) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index c178354db2..9a14d4ceb3 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1302,8 +1302,8 @@ materialized view. real column. If ``False``, the column acts as a virtual column and does not occupy database storage space. - PostgreSQL only supports persisted columns. Oracle only supports virtual - columns. + PostgreSQL < 18 only supports persisted columns. Oracle only supports + virtual columns. .. admonition:: Database limitations From afe6634146d0fe70498976c49d2eb4d745aa9064 Mon Sep 17 00:00:00 2001 From: okaybro <66475772+Chaitanya-Keyal@users.noreply.github.com> Date: Mon, 1 Sep 2025 23:09:41 +0000 Subject: [PATCH 026/116] Fixed #36587 -- Clarified usage of `list.insert()` for upload handlers. Thanks Baptiste Mispelon for the report Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> --- AUTHORS | 1 + docs/topics/http/file-uploads.txt | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/AUTHORS b/AUTHORS index 4b1cc7d357..4204dc9f2b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -209,6 +209,7 @@ answer newbie questions, and generally made Django that much better: Carlton Gibson cedric@terramater.net Chad Whitman + Chaitanya Keyal ChaosKCW Charlie Leifer charly.wilhelm@gmail.com diff --git a/docs/topics/http/file-uploads.txt b/docs/topics/http/file-uploads.txt index f9327808b5..60b3e1a86e 100644 --- a/docs/topics/http/file-uploads.txt +++ b/docs/topics/http/file-uploads.txt @@ -289,9 +289,10 @@ this handler to your upload handlers like this:: request.upload_handlers.insert(0, ProgressBarUploadHandler(request)) -You'd probably want to use ``list.insert()`` in this case (instead of -``append()``) because a progress bar handler would need to run *before* any -other handlers. Remember, the upload handlers are processed in order. +Using ``list.insert()``, as shown above, ensures that the progress bar handler +is placed at the beginning of the list. Since upload handlers are executed in +order, this placement guarantees that the progress bar handler runs before the +default handlers, allowing it to track progress across the entire upload. If you want to replace the upload handlers completely, you can assign a new list:: From 22448a4b65bdd6293192fb7845d31bba0405aeed Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Mon, 29 Sep 2025 17:37:03 +0200 Subject: [PATCH 027/116] Added PostgreSQL 18 to scheduled tests workflow. --- .github/workflows/schedule_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/schedule_tests.yml b/.github/workflows/schedule_tests.yml index f59b357732..ed3b6b9428 100644 --- a/.github/workflows/schedule_tests.yml +++ b/.github/workflows/schedule_tests.yml @@ -141,7 +141,7 @@ jobs: strategy: fail-fast: false matrix: - version: [16, 17] + version: [16, 17, 18] server_side_bindings: [0, 1] runs-on: ubuntu-latest name: PostgreSQL Versions From 8b84364d469e394d9f04b4f96a7da1fc16d93fce Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Mon, 29 Sep 2025 23:01:12 +0200 Subject: [PATCH 028/116] Fixed assertIndexExists() crash when non-index constraint exists on the same columns. --- tests/migrations/test_base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/migrations/test_base.py b/tests/migrations/test_base.py index b636d18ec4..24ae59ca1b 100644 --- a/tests/migrations/test_base.py +++ b/tests/migrations/test_base.py @@ -108,6 +108,7 @@ class MigrationTestBase(TransactionTestCase): .values() if ( c["columns"] == list(columns) + and c["index"] is True and (index_type is None or c["type"] == index_type) and not c["unique"] ) From 906a51e125c3007f86d42b81072a1dad7149af05 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Mon, 29 Sep 2025 23:09:53 +0200 Subject: [PATCH 029/116] Skipped NOT NULL constraints on PostgreSQL 18+. Thanks Simon Charette for the implementation idea. --- django/db/backends/postgresql/introspection.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/django/db/backends/postgresql/introspection.py b/django/db/backends/postgresql/introspection.py index 82013eb191..fc69e0a381 100644 --- a/django/db/backends/postgresql/introspection.py +++ b/django/db/backends/postgresql/introspection.py @@ -206,7 +206,9 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): cl.reloptions FROM pg_constraint AS c JOIN pg_class AS cl ON c.conrelid = cl.oid - WHERE cl.relname = %s AND pg_catalog.pg_table_is_visible(cl.oid) + WHERE cl.relname = %s + AND pg_catalog.pg_table_is_visible(cl.oid) + AND c.contype != 'n' """, [table_name], ) From d29852ae725f673843c46085bb51cbc740d374d7 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Tue, 23 Sep 2025 22:46:38 -0400 Subject: [PATCH 030/116] Fixed #36619 -- Vendored eslint configuration dependencies. This allows the eslint pre-commit hook to run without depending on a prior installation of node modules. Follow-up to 6345a6ff63a8b8af86ee9a025e29984a410c9764. --- eslint-recommended.js | 85 +++ eslint.config.mjs | 6 +- globals.js | 1179 +++++++++++++++++++++++++++++++++++++++++ package.json | 1 - 4 files changed, 1267 insertions(+), 4 deletions(-) create mode 100644 eslint-recommended.js create mode 100644 globals.js diff --git a/eslint-recommended.js b/eslint-recommended.js new file mode 100644 index 0000000000..907c2bdcb7 --- /dev/null +++ b/eslint-recommended.js @@ -0,0 +1,85 @@ +/* https://github.com/eslint/eslint/blob/v9.36.0/packages/js/src/configs/eslint-recommended.js */ + +/** + * @fileoverview Configuration applied when a user configuration extends from + * eslint:recommended. + * @author Nicholas C. Zakas + */ + +"use strict"; + +/* eslint sort-keys: ["error", "asc"] -- Long, so make more readable */ + +/* + * IMPORTANT! + * + * We cannot add a "name" property to this object because it's still used in eslintrc + * which doesn't support the "name" property. If we add a "name" property, it will + * cause an error. + */ + +module.exports = Object.freeze({ + rules: Object.freeze({ + "constructor-super": "error", + "for-direction": "error", + "getter-return": "error", + "no-async-promise-executor": "error", + "no-case-declarations": "error", + "no-class-assign": "error", + "no-compare-neg-zero": "error", + "no-cond-assign": "error", + "no-const-assign": "error", + "no-constant-binary-expression": "error", + "no-constant-condition": "error", + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-dupe-args": "error", + "no-dupe-class-members": "error", + "no-dupe-else-if": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty": "error", + "no-empty-character-class": "error", + "no-empty-pattern": "error", + "no-empty-static-block": "error", + "no-ex-assign": "error", + "no-extra-boolean-cast": "error", + "no-fallthrough": "error", + "no-func-assign": "error", + "no-global-assign": "error", + "no-import-assign": "error", + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-loss-of-precision": "error", + "no-misleading-character-class": "error", + "no-new-native-nonconstructor": "error", + "no-nonoctal-decimal-escape": "error", + "no-obj-calls": "error", + "no-octal": "error", + "no-prototype-builtins": "error", + "no-redeclare": "error", + "no-regex-spaces": "error", + "no-self-assign": "error", + "no-setter-return": "error", + "no-shadow-restricted-names": "error", + "no-sparse-arrays": "error", + "no-this-before-super": "error", + "no-undef": "error", + "no-unexpected-multiline": "error", + "no-unreachable": "error", + "no-unsafe-finally": "error", + "no-unsafe-negation": "error", + "no-unsafe-optional-chaining": "error", + "no-unused-labels": "error", + "no-unused-private-class-members": "error", + "no-unused-vars": "error", + "no-useless-backreference": "error", + "no-useless-catch": "error", + "no-useless-escape": "error", + "no-with": "error", + "require-yield": "error", + "use-isnan": "error", + "valid-typeof": "error", + }), +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 306adb3aa5..c3fd4f33a5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,8 +1,8 @@ -import globals from "globals"; -import js from "@eslint/js"; +import globals from "./globals.js"; +import recommended from "./eslint-recommended.js"; export default [ - js.configs.recommended, + recommended, { files: ["**/*.js"], rules: { diff --git a/globals.js b/globals.js new file mode 100644 index 0000000000..8871f9e8b7 --- /dev/null +++ b/globals.js @@ -0,0 +1,1179 @@ +/* https://github.com/sindresorhus/globals/blob/v16.4.0/globals.json */ + +"use strict"; + + +module.exports = { + "browser": { + "AbortController": false, + "AbortSignal": false, + "AbsoluteOrientationSensor": false, + "AbstractRange": false, + "Accelerometer": false, + "addEventListener": false, + "ai": false, + "AI": false, + "AICreateMonitor": false, + "AITextSession": false, + "alert": false, + "AnalyserNode": false, + "Animation": false, + "AnimationEffect": false, + "AnimationEvent": false, + "AnimationPlaybackEvent": false, + "AnimationTimeline": false, + "AsyncDisposableStack": false, + "atob": false, + "Attr": false, + "Audio": false, + "AudioBuffer": false, + "AudioBufferSourceNode": false, + "AudioContext": false, + "AudioData": false, + "AudioDecoder": false, + "AudioDestinationNode": false, + "AudioEncoder": false, + "AudioListener": false, + "AudioNode": false, + "AudioParam": false, + "AudioParamMap": false, + "AudioProcessingEvent": false, + "AudioScheduledSourceNode": false, + "AudioSinkInfo": false, + "AudioWorklet": false, + "AudioWorkletGlobalScope": false, + "AudioWorkletNode": false, + "AudioWorkletProcessor": false, + "AuthenticatorAssertionResponse": false, + "AuthenticatorAttestationResponse": false, + "AuthenticatorResponse": false, + "BackgroundFetchManager": false, + "BackgroundFetchRecord": false, + "BackgroundFetchRegistration": false, + "BarcodeDetector": false, + "BarProp": false, + "BaseAudioContext": false, + "BatteryManager": false, + "BeforeUnloadEvent": false, + "BiquadFilterNode": false, + "Blob": false, + "BlobEvent": false, + "Bluetooth": false, + "BluetoothCharacteristicProperties": false, + "BluetoothDevice": false, + "BluetoothRemoteGATTCharacteristic": false, + "BluetoothRemoteGATTDescriptor": false, + "BluetoothRemoteGATTServer": false, + "BluetoothRemoteGATTService": false, + "BluetoothUUID": false, + "blur": false, + "BroadcastChannel": false, + "BrowserCaptureMediaStreamTrack": false, + "btoa": false, + "ByteLengthQueuingStrategy": false, + "Cache": false, + "caches": false, + "CacheStorage": false, + "cancelAnimationFrame": false, + "cancelIdleCallback": false, + "CanvasCaptureMediaStream": false, + "CanvasCaptureMediaStreamTrack": false, + "CanvasGradient": false, + "CanvasPattern": false, + "CanvasRenderingContext2D": false, + "CaptureController": false, + "CaretPosition": false, + "CDATASection": false, + "ChannelMergerNode": false, + "ChannelSplitterNode": false, + "ChapterInformation": false, + "CharacterBoundsUpdateEvent": false, + "CharacterData": false, + "clearInterval": false, + "clearTimeout": false, + "clientInformation": false, + "Clipboard": false, + "ClipboardChangeEvent": false, + "ClipboardEvent": false, + "ClipboardItem": false, + "close": false, + "closed": false, + "CloseEvent": false, + "CloseWatcher": false, + "CommandEvent": false, + "Comment": false, + "CompositionEvent": false, + "CompressionStream": false, + "confirm": false, + "console": false, + "ConstantSourceNode": false, + "ContentVisibilityAutoStateChangeEvent": false, + "ConvolverNode": false, + "CookieChangeEvent": false, + "CookieDeprecationLabel": false, + "cookieStore": false, + "CookieStore": false, + "CookieStoreManager": false, + "CountQueuingStrategy": false, + "createImageBitmap": false, + "CreateMonitor": false, + "Credential": false, + "credentialless": false, + "CredentialsContainer": false, + "CropTarget": false, + "crossOriginIsolated": false, + "crypto": false, + "Crypto": false, + "CryptoKey": false, + "CSPViolationReportBody": false, + "CSS": false, + "CSSAnimation": false, + "CSSConditionRule": false, + "CSSContainerRule": false, + "CSSCounterStyleRule": false, + "CSSFontFaceRule": false, + "CSSFontFeatureValuesRule": false, + "CSSFontPaletteValuesRule": false, + "CSSFunctionDeclarations": false, + "CSSFunctionDescriptors": false, + "CSSFunctionRule": false, + "CSSGroupingRule": false, + "CSSImageValue": false, + "CSSImportRule": false, + "CSSKeyframeRule": false, + "CSSKeyframesRule": false, + "CSSKeywordValue": false, + "CSSLayerBlockRule": false, + "CSSLayerStatementRule": false, + "CSSMarginRule": false, + "CSSMathClamp": false, + "CSSMathInvert": false, + "CSSMathMax": false, + "CSSMathMin": false, + "CSSMathNegate": false, + "CSSMathProduct": false, + "CSSMathSum": false, + "CSSMathValue": false, + "CSSMatrixComponent": false, + "CSSMediaRule": false, + "CSSNamespaceRule": false, + "CSSNestedDeclarations": false, + "CSSNumericArray": false, + "CSSNumericValue": false, + "CSSPageDescriptors": false, + "CSSPageRule": false, + "CSSPerspective": false, + "CSSPositionTryDescriptors": false, + "CSSPositionTryRule": false, + "CSSPositionValue": false, + "CSSPropertyRule": false, + "CSSRotate": false, + "CSSRule": false, + "CSSRuleList": false, + "CSSScale": false, + "CSSScopeRule": false, + "CSSSkew": false, + "CSSSkewX": false, + "CSSSkewY": false, + "CSSStartingStyleRule": false, + "CSSStyleDeclaration": false, + "CSSStyleRule": false, + "CSSStyleSheet": false, + "CSSStyleValue": false, + "CSSSupportsRule": false, + "CSSTransformComponent": false, + "CSSTransformValue": false, + "CSSTransition": false, + "CSSTranslate": false, + "CSSUnitValue": false, + "CSSUnparsedValue": false, + "CSSVariableReferenceValue": false, + "CSSViewTransitionRule": false, + "currentFrame": false, + "currentTime": false, + "CustomElementRegistry": false, + "customElements": false, + "CustomEvent": false, + "CustomStateSet": false, + "DataTransfer": false, + "DataTransferItem": false, + "DataTransferItemList": false, + "DecompressionStream": false, + "DelayNode": false, + "DelegatedInkTrailPresenter": false, + "DeviceMotionEvent": false, + "DeviceMotionEventAcceleration": false, + "DeviceMotionEventRotationRate": false, + "DeviceOrientationEvent": false, + "devicePixelRatio": false, + "DevicePosture": false, + "dispatchEvent": false, + "DisposableStack": false, + "document": false, + "Document": false, + "DocumentFragment": false, + "documentPictureInPicture": false, + "DocumentPictureInPicture": false, + "DocumentPictureInPictureEvent": false, + "DocumentTimeline": false, + "DocumentType": false, + "DOMError": false, + "DOMException": false, + "DOMImplementation": false, + "DOMMatrix": false, + "DOMMatrixReadOnly": false, + "DOMParser": false, + "DOMPoint": false, + "DOMPointReadOnly": false, + "DOMQuad": false, + "DOMRect": false, + "DOMRectList": false, + "DOMRectReadOnly": false, + "DOMStringList": false, + "DOMStringMap": false, + "DOMTokenList": false, + "DragEvent": false, + "DynamicsCompressorNode": false, + "EditContext": false, + "Element": false, + "ElementInternals": false, + "EncodedAudioChunk": false, + "EncodedVideoChunk": false, + "ErrorEvent": false, + "event": false, + "Event": false, + "EventCounts": false, + "EventSource": false, + "EventTarget": false, + "external": false, + "External": false, + "EyeDropper": false, + "FeaturePolicy": false, + "FederatedCredential": false, + "fence": false, + "Fence": false, + "FencedFrameConfig": false, + "fetch": false, + "fetchLater": false, + "FetchLaterResult": false, + "File": false, + "FileList": false, + "FileReader": false, + "FileSystem": false, + "FileSystemDirectoryEntry": false, + "FileSystemDirectoryHandle": false, + "FileSystemDirectoryReader": false, + "FileSystemEntry": false, + "FileSystemFileEntry": false, + "FileSystemFileHandle": false, + "FileSystemHandle": false, + "FileSystemObserver": false, + "FileSystemWritableFileStream": false, + "find": false, + "focus": false, + "FocusEvent": false, + "FontData": false, + "FontFace": false, + "FontFaceSet": false, + "FontFaceSetLoadEvent": false, + "FormData": false, + "FormDataEvent": false, + "FragmentDirective": false, + "frameElement": false, + "frames": false, + "GainNode": false, + "Gamepad": false, + "GamepadAxisMoveEvent": false, + "GamepadButton": false, + "GamepadButtonEvent": false, + "GamepadEvent": false, + "GamepadHapticActuator": false, + "GamepadPose": false, + "Geolocation": false, + "GeolocationCoordinates": false, + "GeolocationPosition": false, + "GeolocationPositionError": false, + "getComputedStyle": false, + "getScreenDetails": false, + "getSelection": false, + "GPU": false, + "GPUAdapter": false, + "GPUAdapterInfo": false, + "GPUBindGroup": false, + "GPUBindGroupLayout": false, + "GPUBuffer": false, + "GPUBufferUsage": false, + "GPUCanvasContext": false, + "GPUColorWrite": false, + "GPUCommandBuffer": false, + "GPUCommandEncoder": false, + "GPUCompilationInfo": false, + "GPUCompilationMessage": false, + "GPUComputePassEncoder": false, + "GPUComputePipeline": false, + "GPUDevice": false, + "GPUDeviceLostInfo": false, + "GPUError": false, + "GPUExternalTexture": false, + "GPUInternalError": false, + "GPUMapMode": false, + "GPUOutOfMemoryError": false, + "GPUPipelineError": false, + "GPUPipelineLayout": false, + "GPUQuerySet": false, + "GPUQueue": false, + "GPURenderBundle": false, + "GPURenderBundleEncoder": false, + "GPURenderPassEncoder": false, + "GPURenderPipeline": false, + "GPUSampler": false, + "GPUShaderModule": false, + "GPUShaderStage": false, + "GPUSupportedFeatures": false, + "GPUSupportedLimits": false, + "GPUTexture": false, + "GPUTextureUsage": false, + "GPUTextureView": false, + "GPUUncapturedErrorEvent": false, + "GPUValidationError": false, + "GravitySensor": false, + "Gyroscope": false, + "HashChangeEvent": false, + "Headers": false, + "HID": false, + "HIDConnectionEvent": false, + "HIDDevice": false, + "HIDInputReportEvent": false, + "Highlight": false, + "HighlightRegistry": false, + "history": false, + "History": false, + "HTMLAllCollection": false, + "HTMLAnchorElement": false, + "HTMLAreaElement": false, + "HTMLAudioElement": false, + "HTMLBaseElement": false, + "HTMLBodyElement": false, + "HTMLBRElement": false, + "HTMLButtonElement": false, + "HTMLCanvasElement": false, + "HTMLCollection": false, + "HTMLDataElement": false, + "HTMLDataListElement": false, + "HTMLDetailsElement": false, + "HTMLDialogElement": false, + "HTMLDirectoryElement": false, + "HTMLDivElement": false, + "HTMLDListElement": false, + "HTMLDocument": false, + "HTMLElement": false, + "HTMLEmbedElement": false, + "HTMLFencedFrameElement": false, + "HTMLFieldSetElement": false, + "HTMLFontElement": false, + "HTMLFormControlsCollection": false, + "HTMLFormElement": false, + "HTMLFrameElement": false, + "HTMLFrameSetElement": false, + "HTMLHeadElement": false, + "HTMLHeadingElement": false, + "HTMLHRElement": false, + "HTMLHtmlElement": false, + "HTMLIFrameElement": false, + "HTMLImageElement": false, + "HTMLInputElement": false, + "HTMLLabelElement": false, + "HTMLLegendElement": false, + "HTMLLIElement": false, + "HTMLLinkElement": false, + "HTMLMapElement": false, + "HTMLMarqueeElement": false, + "HTMLMediaElement": false, + "HTMLMenuElement": false, + "HTMLMetaElement": false, + "HTMLMeterElement": false, + "HTMLModElement": false, + "HTMLObjectElement": false, + "HTMLOListElement": false, + "HTMLOptGroupElement": false, + "HTMLOptionElement": false, + "HTMLOptionsCollection": false, + "HTMLOutputElement": false, + "HTMLParagraphElement": false, + "HTMLParamElement": false, + "HTMLPictureElement": false, + "HTMLPreElement": false, + "HTMLProgressElement": false, + "HTMLQuoteElement": false, + "HTMLScriptElement": false, + "HTMLSelectedContentElement": false, + "HTMLSelectElement": false, + "HTMLSlotElement": false, + "HTMLSourceElement": false, + "HTMLSpanElement": false, + "HTMLStyleElement": false, + "HTMLTableCaptionElement": false, + "HTMLTableCellElement": false, + "HTMLTableColElement": false, + "HTMLTableElement": false, + "HTMLTableRowElement": false, + "HTMLTableSectionElement": false, + "HTMLTemplateElement": false, + "HTMLTextAreaElement": false, + "HTMLTimeElement": false, + "HTMLTitleElement": false, + "HTMLTrackElement": false, + "HTMLUListElement": false, + "HTMLUnknownElement": false, + "HTMLVideoElement": false, + "IDBCursor": false, + "IDBCursorWithValue": false, + "IDBDatabase": false, + "IDBFactory": false, + "IDBIndex": false, + "IDBKeyRange": false, + "IDBObjectStore": false, + "IDBOpenDBRequest": false, + "IDBRequest": false, + "IDBTransaction": false, + "IDBVersionChangeEvent": false, + "IdentityCredential": false, + "IdentityCredentialError": false, + "IdentityProvider": false, + "IdleDeadline": false, + "IdleDetector": false, + "IIRFilterNode": false, + "Image": false, + "ImageBitmap": false, + "ImageBitmapRenderingContext": false, + "ImageCapture": false, + "ImageData": false, + "ImageDecoder": false, + "ImageTrack": false, + "ImageTrackList": false, + "indexedDB": false, + "Ink": false, + "innerHeight": false, + "innerWidth": false, + "InputDeviceCapabilities": false, + "InputDeviceInfo": false, + "InputEvent": false, + "IntegrityViolationReportBody": false, + "IntersectionObserver": false, + "IntersectionObserverEntry": false, + "isSecureContext": false, + "Keyboard": false, + "KeyboardEvent": false, + "KeyboardLayoutMap": false, + "KeyframeEffect": false, + "LanguageDetector": false, + "LargestContentfulPaint": false, + "LaunchParams": false, + "launchQueue": false, + "LaunchQueue": false, + "LayoutShift": false, + "LayoutShiftAttribution": false, + "length": false, + "LinearAccelerationSensor": false, + "localStorage": false, + "location": true, + "Location": false, + "locationbar": false, + "Lock": false, + "LockManager": false, + "matchMedia": false, + "MathMLElement": false, + "MediaCapabilities": false, + "MediaCapabilitiesInfo": false, + "MediaDeviceInfo": false, + "MediaDevices": false, + "MediaElementAudioSourceNode": false, + "MediaEncryptedEvent": false, + "MediaError": false, + "MediaKeyError": false, + "MediaKeyMessageEvent": false, + "MediaKeys": false, + "MediaKeySession": false, + "MediaKeyStatusMap": false, + "MediaKeySystemAccess": false, + "MediaList": false, + "MediaMetadata": false, + "MediaQueryList": false, + "MediaQueryListEvent": false, + "MediaRecorder": false, + "MediaRecorderErrorEvent": false, + "MediaSession": false, + "MediaSource": false, + "MediaSourceHandle": false, + "MediaStream": false, + "MediaStreamAudioDestinationNode": false, + "MediaStreamAudioSourceNode": false, + "MediaStreamEvent": false, + "MediaStreamTrack": false, + "MediaStreamTrackAudioSourceNode": false, + "MediaStreamTrackAudioStats": false, + "MediaStreamTrackEvent": false, + "MediaStreamTrackGenerator": false, + "MediaStreamTrackProcessor": false, + "MediaStreamTrackVideoStats": false, + "menubar": false, + "MessageChannel": false, + "MessageEvent": false, + "MessagePort": false, + "MIDIAccess": false, + "MIDIConnectionEvent": false, + "MIDIInput": false, + "MIDIInputMap": false, + "MIDIMessageEvent": false, + "MIDIOutput": false, + "MIDIOutputMap": false, + "MIDIPort": false, + "MimeType": false, + "MimeTypeArray": false, + "model": false, + "ModelGenericSession": false, + "ModelManager": false, + "MouseEvent": false, + "moveBy": false, + "moveTo": false, + "MutationEvent": false, + "MutationObserver": false, + "MutationRecord": false, + "name": false, + "NamedNodeMap": false, + "NavigateEvent": false, + "navigation": false, + "Navigation": false, + "NavigationActivation": false, + "NavigationCurrentEntryChangeEvent": false, + "NavigationDestination": false, + "NavigationHistoryEntry": false, + "NavigationPreloadManager": false, + "NavigationTransition": false, + "navigator": false, + "Navigator": false, + "NavigatorLogin": false, + "NavigatorManagedData": false, + "NavigatorUAData": false, + "NetworkInformation": false, + "Node": false, + "NodeFilter": false, + "NodeIterator": false, + "NodeList": false, + "Notification": false, + "NotifyPaintEvent": false, + "NotRestoredReasonDetails": false, + "NotRestoredReasons": false, + "Observable": false, + "OfflineAudioCompletionEvent": false, + "OfflineAudioContext": false, + "offscreenBuffering": false, + "OffscreenCanvas": false, + "OffscreenCanvasRenderingContext2D": false, + "onabort": true, + "onafterprint": true, + "onanimationcancel": true, + "onanimationend": true, + "onanimationiteration": true, + "onanimationstart": true, + "onappinstalled": true, + "onauxclick": true, + "onbeforeinput": true, + "onbeforeinstallprompt": true, + "onbeforematch": true, + "onbeforeprint": true, + "onbeforetoggle": true, + "onbeforeunload": true, + "onbeforexrselect": true, + "onblur": true, + "oncancel": true, + "oncanplay": true, + "oncanplaythrough": true, + "onchange": true, + "onclick": true, + "onclose": true, + "oncommand": true, + "oncontentvisibilityautostatechange": true, + "oncontextlost": true, + "oncontextmenu": true, + "oncontextrestored": true, + "oncopy": true, + "oncuechange": true, + "oncut": true, + "ondblclick": true, + "ondevicemotion": true, + "ondeviceorientation": true, + "ondeviceorientationabsolute": true, + "ondrag": true, + "ondragend": true, + "ondragenter": true, + "ondragleave": true, + "ondragover": true, + "ondragstart": true, + "ondrop": true, + "ondurationchange": true, + "onemptied": true, + "onended": true, + "onerror": true, + "onfocus": true, + "onformdata": true, + "ongamepadconnected": true, + "ongamepaddisconnected": true, + "ongotpointercapture": true, + "onhashchange": true, + "oninput": true, + "oninvalid": true, + "onkeydown": true, + "onkeypress": true, + "onkeyup": true, + "onlanguagechange": true, + "onload": true, + "onloadeddata": true, + "onloadedmetadata": true, + "onloadstart": true, + "onlostpointercapture": true, + "onmessage": true, + "onmessageerror": true, + "onmousedown": true, + "onmouseenter": true, + "onmouseleave": true, + "onmousemove": true, + "onmouseout": true, + "onmouseover": true, + "onmouseup": true, + "onmousewheel": true, + "onoffline": true, + "ononline": true, + "onpagehide": true, + "onpagereveal": true, + "onpageshow": true, + "onpageswap": true, + "onpaste": true, + "onpause": true, + "onplay": true, + "onplaying": true, + "onpointercancel": true, + "onpointerdown": true, + "onpointerenter": true, + "onpointerleave": true, + "onpointermove": true, + "onpointerout": true, + "onpointerover": true, + "onpointerrawupdate": true, + "onpointerup": true, + "onpopstate": true, + "onprogress": true, + "onratechange": true, + "onrejectionhandled": true, + "onreset": true, + "onresize": true, + "onscroll": true, + "onscrollend": true, + "onscrollsnapchange": true, + "onscrollsnapchanging": true, + "onsearch": true, + "onsecuritypolicyviolation": true, + "onseeked": true, + "onseeking": true, + "onselect": true, + "onselectionchange": true, + "onselectstart": true, + "onslotchange": true, + "onstalled": true, + "onstorage": true, + "onsubmit": true, + "onsuspend": true, + "ontimeupdate": true, + "ontoggle": true, + "ontransitioncancel": true, + "ontransitionend": true, + "ontransitionrun": true, + "ontransitionstart": true, + "onunhandledrejection": true, + "onunload": true, + "onvolumechange": true, + "onwaiting": true, + "onwheel": true, + "open": false, + "opener": false, + "Option": false, + "OrientationSensor": false, + "origin": false, + "originAgentCluster": false, + "OscillatorNode": false, + "OTPCredential": false, + "outerHeight": false, + "outerWidth": false, + "OverconstrainedError": false, + "PageRevealEvent": false, + "PageSwapEvent": false, + "PageTransitionEvent": false, + "pageXOffset": false, + "pageYOffset": false, + "PannerNode": false, + "parent": false, + "PasswordCredential": false, + "Path2D": false, + "PaymentAddress": false, + "PaymentManager": false, + "PaymentMethodChangeEvent": false, + "PaymentRequest": false, + "PaymentRequestUpdateEvent": false, + "PaymentResponse": false, + "performance": false, + "Performance": false, + "PerformanceElementTiming": false, + "PerformanceEntry": false, + "PerformanceEventTiming": false, + "PerformanceLongAnimationFrameTiming": false, + "PerformanceLongTaskTiming": false, + "PerformanceMark": false, + "PerformanceMeasure": false, + "PerformanceNavigation": false, + "PerformanceNavigationTiming": false, + "PerformanceObserver": false, + "PerformanceObserverEntryList": false, + "PerformancePaintTiming": false, + "PerformanceResourceTiming": false, + "PerformanceScriptTiming": false, + "PerformanceServerTiming": false, + "PerformanceTiming": false, + "PeriodicSyncManager": false, + "PeriodicWave": false, + "Permissions": false, + "PermissionStatus": false, + "PERSISTENT": false, + "personalbar": false, + "PictureInPictureEvent": false, + "PictureInPictureWindow": false, + "Plugin": false, + "PluginArray": false, + "PointerEvent": false, + "PopStateEvent": false, + "postMessage": false, + "Presentation": false, + "PresentationAvailability": false, + "PresentationConnection": false, + "PresentationConnectionAvailableEvent": false, + "PresentationConnectionCloseEvent": false, + "PresentationConnectionList": false, + "PresentationReceiver": false, + "PresentationRequest": false, + "PressureObserver": false, + "PressureRecord": false, + "print": false, + "ProcessingInstruction": false, + "Profiler": false, + "ProgressEvent": false, + "PromiseRejectionEvent": false, + "prompt": false, + "ProtectedAudience": false, + "PublicKeyCredential": false, + "PushManager": false, + "PushSubscription": false, + "PushSubscriptionOptions": false, + "queryLocalFonts": false, + "queueMicrotask": false, + "QuotaExceededError": false, + "RadioNodeList": false, + "Range": false, + "ReadableByteStreamController": false, + "ReadableStream": false, + "ReadableStreamBYOBReader": false, + "ReadableStreamBYOBRequest": false, + "ReadableStreamDefaultController": false, + "ReadableStreamDefaultReader": false, + "registerProcessor": false, + "RelativeOrientationSensor": false, + "RemotePlayback": false, + "removeEventListener": false, + "ReportBody": false, + "reportError": false, + "ReportingObserver": false, + "Request": false, + "requestAnimationFrame": false, + "requestIdleCallback": false, + "resizeBy": false, + "ResizeObserver": false, + "ResizeObserverEntry": false, + "ResizeObserverSize": false, + "resizeTo": false, + "Response": false, + "RestrictionTarget": false, + "RTCCertificate": false, + "RTCDataChannel": false, + "RTCDataChannelEvent": false, + "RTCDtlsTransport": false, + "RTCDTMFSender": false, + "RTCDTMFToneChangeEvent": false, + "RTCEncodedAudioFrame": false, + "RTCEncodedVideoFrame": false, + "RTCError": false, + "RTCErrorEvent": false, + "RTCIceCandidate": false, + "RTCIceTransport": false, + "RTCPeerConnection": false, + "RTCPeerConnectionIceErrorEvent": false, + "RTCPeerConnectionIceEvent": false, + "RTCRtpReceiver": false, + "RTCRtpScriptTransform": false, + "RTCRtpSender": false, + "RTCRtpTransceiver": false, + "RTCSctpTransport": false, + "RTCSessionDescription": false, + "RTCStatsReport": false, + "RTCTrackEvent": false, + "sampleRate": false, + "scheduler": false, + "Scheduler": false, + "Scheduling": false, + "screen": false, + "Screen": false, + "ScreenDetailed": false, + "ScreenDetails": false, + "screenLeft": false, + "ScreenOrientation": false, + "screenTop": false, + "screenX": false, + "screenY": false, + "ScriptProcessorNode": false, + "scroll": false, + "scrollbars": false, + "scrollBy": false, + "ScrollTimeline": false, + "scrollTo": false, + "scrollX": false, + "scrollY": false, + "SecurityPolicyViolationEvent": false, + "Selection": false, + "self": false, + "Sensor": false, + "SensorErrorEvent": false, + "Serial": false, + "SerialPort": false, + "ServiceWorker": false, + "ServiceWorkerContainer": false, + "ServiceWorkerRegistration": false, + "sessionStorage": false, + "setInterval": false, + "setTimeout": false, + "ShadowRoot": false, + "sharedStorage": false, + "SharedStorage": false, + "SharedStorageAppendMethod": false, + "SharedStorageClearMethod": false, + "SharedStorageDeleteMethod": false, + "SharedStorageModifierMethod": false, + "SharedStorageSetMethod": false, + "SharedStorageWorklet": false, + "SharedWorker": false, + "showDirectoryPicker": false, + "showOpenFilePicker": false, + "showSaveFilePicker": false, + "SnapEvent": false, + "SourceBuffer": false, + "SourceBufferList": false, + "SpeechGrammar": false, + "SpeechGrammarList": false, + "SpeechRecognition": false, + "SpeechRecognitionErrorEvent": false, + "SpeechRecognitionEvent": false, + "speechSynthesis": false, + "SpeechSynthesis": false, + "SpeechSynthesisErrorEvent": false, + "SpeechSynthesisEvent": false, + "SpeechSynthesisUtterance": false, + "SpeechSynthesisVoice": false, + "StaticRange": false, + "status": false, + "statusbar": false, + "StereoPannerNode": false, + "stop": false, + "Storage": false, + "StorageBucket": false, + "StorageBucketManager": false, + "StorageEvent": false, + "StorageManager": false, + "structuredClone": false, + "styleMedia": false, + "StylePropertyMap": false, + "StylePropertyMapReadOnly": false, + "StyleSheet": false, + "StyleSheetList": false, + "SubmitEvent": false, + "Subscriber": false, + "SubtleCrypto": false, + "Summarizer": false, + "SuppressedError": false, + "SVGAElement": false, + "SVGAngle": false, + "SVGAnimatedAngle": false, + "SVGAnimatedBoolean": false, + "SVGAnimatedEnumeration": false, + "SVGAnimatedInteger": false, + "SVGAnimatedLength": false, + "SVGAnimatedLengthList": false, + "SVGAnimatedNumber": false, + "SVGAnimatedNumberList": false, + "SVGAnimatedPreserveAspectRatio": false, + "SVGAnimatedRect": false, + "SVGAnimatedString": false, + "SVGAnimatedTransformList": false, + "SVGAnimateElement": false, + "SVGAnimateMotionElement": false, + "SVGAnimateTransformElement": false, + "SVGAnimationElement": false, + "SVGCircleElement": false, + "SVGClipPathElement": false, + "SVGComponentTransferFunctionElement": false, + "SVGDefsElement": false, + "SVGDescElement": false, + "SVGElement": false, + "SVGEllipseElement": false, + "SVGFEBlendElement": false, + "SVGFEColorMatrixElement": false, + "SVGFEComponentTransferElement": false, + "SVGFECompositeElement": false, + "SVGFEConvolveMatrixElement": false, + "SVGFEDiffuseLightingElement": false, + "SVGFEDisplacementMapElement": false, + "SVGFEDistantLightElement": false, + "SVGFEDropShadowElement": false, + "SVGFEFloodElement": false, + "SVGFEFuncAElement": false, + "SVGFEFuncBElement": false, + "SVGFEFuncGElement": false, + "SVGFEFuncRElement": false, + "SVGFEGaussianBlurElement": false, + "SVGFEImageElement": false, + "SVGFEMergeElement": false, + "SVGFEMergeNodeElement": false, + "SVGFEMorphologyElement": false, + "SVGFEOffsetElement": false, + "SVGFEPointLightElement": false, + "SVGFESpecularLightingElement": false, + "SVGFESpotLightElement": false, + "SVGFETileElement": false, + "SVGFETurbulenceElement": false, + "SVGFilterElement": false, + "SVGForeignObjectElement": false, + "SVGGElement": false, + "SVGGeometryElement": false, + "SVGGradientElement": false, + "SVGGraphicsElement": false, + "SVGImageElement": false, + "SVGLength": false, + "SVGLengthList": false, + "SVGLinearGradientElement": false, + "SVGLineElement": false, + "SVGMarkerElement": false, + "SVGMaskElement": false, + "SVGMatrix": false, + "SVGMetadataElement": false, + "SVGMPathElement": false, + "SVGNumber": false, + "SVGNumberList": false, + "SVGPathElement": false, + "SVGPatternElement": false, + "SVGPoint": false, + "SVGPointList": false, + "SVGPolygonElement": false, + "SVGPolylineElement": false, + "SVGPreserveAspectRatio": false, + "SVGRadialGradientElement": false, + "SVGRect": false, + "SVGRectElement": false, + "SVGScriptElement": false, + "SVGSetElement": false, + "SVGStopElement": false, + "SVGStringList": false, + "SVGStyleElement": false, + "SVGSVGElement": false, + "SVGSwitchElement": false, + "SVGSymbolElement": false, + "SVGTextContentElement": false, + "SVGTextElement": false, + "SVGTextPathElement": false, + "SVGTextPositioningElement": false, + "SVGTitleElement": false, + "SVGTransform": false, + "SVGTransformList": false, + "SVGTSpanElement": false, + "SVGUnitTypes": false, + "SVGUseElement": false, + "SVGViewElement": false, + "SyncManager": false, + "TaskAttributionTiming": false, + "TaskController": false, + "TaskPriorityChangeEvent": false, + "TaskSignal": false, + "TEMPORARY": false, + "Text": false, + "TextDecoder": false, + "TextDecoderStream": false, + "TextEncoder": false, + "TextEncoderStream": false, + "TextEvent": false, + "TextFormat": false, + "TextFormatUpdateEvent": false, + "TextMetrics": false, + "TextTrack": false, + "TextTrackCue": false, + "TextTrackCueList": false, + "TextTrackList": false, + "TextUpdateEvent": false, + "TimeEvent": false, + "TimeRanges": false, + "ToggleEvent": false, + "toolbar": false, + "top": false, + "Touch": false, + "TouchEvent": false, + "TouchList": false, + "TrackEvent": false, + "TransformStream": false, + "TransformStreamDefaultController": false, + "TransitionEvent": false, + "Translator": false, + "TreeWalker": false, + "TrustedHTML": false, + "TrustedScript": false, + "TrustedScriptURL": false, + "TrustedTypePolicy": false, + "TrustedTypePolicyFactory": false, + "trustedTypes": false, + "UIEvent": false, + "URL": false, + "URLPattern": false, + "URLSearchParams": false, + "USB": false, + "USBAlternateInterface": false, + "USBConfiguration": false, + "USBConnectionEvent": false, + "USBDevice": false, + "USBEndpoint": false, + "USBInterface": false, + "USBInTransferResult": false, + "USBIsochronousInTransferPacket": false, + "USBIsochronousInTransferResult": false, + "USBIsochronousOutTransferPacket": false, + "USBIsochronousOutTransferResult": false, + "USBOutTransferResult": false, + "UserActivation": false, + "ValidityState": false, + "VideoColorSpace": false, + "VideoDecoder": false, + "VideoEncoder": false, + "VideoFrame": false, + "VideoPlaybackQuality": false, + "viewport": false, + "Viewport": false, + "ViewTimeline": false, + "ViewTransition": false, + "ViewTransitionTypeSet": false, + "VirtualKeyboard": false, + "VirtualKeyboardGeometryChangeEvent": false, + "VisibilityStateEntry": false, + "visualViewport": false, + "VisualViewport": false, + "VTTCue": false, + "VTTRegion": false, + "WakeLock": false, + "WakeLockSentinel": false, + "WaveShaperNode": false, + "WebAssembly": false, + "WebGL2RenderingContext": false, + "WebGLActiveInfo": false, + "WebGLBuffer": false, + "WebGLContextEvent": false, + "WebGLFramebuffer": false, + "WebGLObject": false, + "WebGLProgram": false, + "WebGLQuery": false, + "WebGLRenderbuffer": false, + "WebGLRenderingContext": false, + "WebGLSampler": false, + "WebGLShader": false, + "WebGLShaderPrecisionFormat": false, + "WebGLSync": false, + "WebGLTexture": false, + "WebGLTransformFeedback": false, + "WebGLUniformLocation": false, + "WebGLVertexArrayObject": false, + "WebSocket": false, + "WebSocketError": false, + "WebSocketStream": false, + "WebTransport": false, + "WebTransportBidirectionalStream": false, + "WebTransportDatagramDuplexStream": false, + "WebTransportError": false, + "WebTransportReceiveStream": false, + "WebTransportSendStream": false, + "WGSLLanguageFeatures": false, + "WheelEvent": false, + "when": false, + "window": false, + "Window": false, + "WindowControlsOverlay": false, + "WindowControlsOverlayGeometryChangeEvent": false, + "Worker": false, + "Worklet": false, + "WorkletGlobalScope": false, + "WritableStream": false, + "WritableStreamDefaultController": false, + "WritableStreamDefaultWriter": false, + "XMLDocument": false, + "XMLHttpRequest": false, + "XMLHttpRequestEventTarget": false, + "XMLHttpRequestUpload": false, + "XMLSerializer": false, + "XPathEvaluator": false, + "XPathExpression": false, + "XPathResult": false, + "XRAnchor": false, + "XRAnchorSet": false, + "XRBoundedReferenceSpace": false, + "XRCamera": false, + "XRCPUDepthInformation": false, + "XRDepthInformation": false, + "XRDOMOverlayState": false, + "XRFrame": false, + "XRHand": false, + "XRHitTestResult": false, + "XRHitTestSource": false, + "XRInputSource": false, + "XRInputSourceArray": false, + "XRInputSourceEvent": false, + "XRInputSourcesChangeEvent": false, + "XRJointPose": false, + "XRJointSpace": false, + "XRLayer": false, + "XRLightEstimate": false, + "XRLightProbe": false, + "XRPose": false, + "XRRay": false, + "XRReferenceSpace": false, + "XRReferenceSpaceEvent": false, + "XRRenderState": false, + "XRRigidTransform": false, + "XRSession": false, + "XRSessionEvent": false, + "XRSpace": false, + "XRSystem": false, + "XRTransientInputHitTestResult": false, + "XRTransientInputHitTestSource": false, + "XRView": false, + "XRViewerPose": false, + "XRViewport": false, + "XRWebGLBinding": false, + "XRWebGLDepthInformation": false, + "XRWebGLLayer": false, + "XSLTProcessor": false + }, + "commonjs": { + "exports": true, + "global": false, + "module": false, + "require": false + } +}; diff --git a/package.json b/package.json index 396ffd8653..86be20ce3a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "devDependencies": { "eslint": "^9.36.0", "puppeteer": "^24.22.0", - "globals": "^16.4.0", "grunt": "^1.6.1", "grunt-cli": "^1.5.0", "grunt-contrib-qunit": "^10.1.1", From 8b241f84e25f679c459393dab2947c8354eb01a9 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Fri, 19 Sep 2025 12:27:05 +0200 Subject: [PATCH 031/116] Fixed #36614 -- Deprecated QuerySet.values_list(flat=True) without a field. Thanks to Jacob Walls and Simon Charette for their input. co-authored-by: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> --- django/db/models/query.py | 12 ++++++++++ docs/internals/deprecation.txt | 3 ++- docs/releases/6.1.txt | 4 +++- tests/lookup/tests.py | 42 ++++++++++++++++++++++++++++++---- 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/django/db/models/query.py b/django/db/models/query.py index 8edae41e5f..b404fd1875 100644 --- a/django/db/models/query.py +++ b/django/db/models/query.py @@ -1475,6 +1475,18 @@ class QuerySet(AltersData): "field." ) elif not fields: + # RemovedInDjango70Warning: When the deprecation ends, replace + # with: + # raise TypeError( + # "'flat' is not valid when values_list is called with no " + # "fields." + # ) + warnings.warn( + "Calling values_list() with no field name and flat=True " + "is deprecated. Pass an explicit field name instead, like " + "'pk'.", + RemovedInDjango70Warning, + ) fields = [self.model._meta.concrete_fields[0].attname] field_names = {f: False for f in fields if not hasattr(f, "resolve_expression")} diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index f2a6e9b545..2473d2b4f7 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -56,7 +56,8 @@ details on these changes. See the :ref:`Django 6.1 release notes ` for more details on these changes. -* ... +* Calling :meth:`.QuerySet.values_list` with ``flat=True`` and no field name + will raise ``TypeError``. .. _deprecation-removed-in-6.1: diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 56a222f3e3..97a9d05d9d 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -262,7 +262,9 @@ Features deprecated in 6.1 Miscellaneous ------------- -* ... +* Calling :meth:`.QuerySet.values_list` with ``flat=True`` and no field name + is deprecated. Pass an explicit field name, like + ``values_list("pk", flat=True)``. Features removed in 6.1 ======================= diff --git a/tests/lookup/tests.py b/tests/lookup/tests.py index f6f73e9fac..5b9dd8e5ec 100644 --- a/tests/lookup/tests.py +++ b/tests/lookup/tests.py @@ -30,7 +30,8 @@ from django.db.models.lookups import ( LessThanOrEqual, ) from django.test import TestCase, skipUnlessDBFeature -from django.test.utils import isolate_apps, register_lookup +from django.test.utils import ignore_warnings, isolate_apps, register_lookup +from django.utils.deprecation import RemovedInDjango70Warning from .models import ( Article, @@ -500,13 +501,19 @@ class LookupTests(TestCase): self.assertEqual(arts1.slug, "a1") self.assertEqual(arts1.headline, "Article 1") + # RemovedInDjango70Warning: When the deprecation ends, remove this + # test. def test_in_bulk_values_list_flat_empty(self): - arts = Article.objects.values_list(flat=True).in_bulk([]) + with ignore_warnings(category=RemovedInDjango70Warning): + arts = Article.objects.values_list(flat=True).in_bulk([]) self.assertEqual(arts, {}) + # RemovedInDjango70Warning: When the deprecation ends, remove this + # test. def test_in_bulk_values_list_flat_all(self): Article.objects.exclude(pk__in=[self.a1.pk, self.a2.pk]).delete() - arts = Article.objects.values_list(flat=True).in_bulk() + with ignore_warnings(category=RemovedInDjango70Warning): + arts = Article.objects.values_list(flat=True).in_bulk() self.assertEqual( arts, { @@ -515,8 +522,13 @@ class LookupTests(TestCase): }, ) + # RemovedInDjango70Warning: When the deprecation ends, remove this + # test. def test_in_bulk_values_list_flat_pks(self): - arts = Article.objects.values_list(flat=True).in_bulk([self.a1.pk, self.a2.pk]) + with ignore_warnings(category=RemovedInDjango70Warning): + arts = Article.objects.values_list(flat=True).in_bulk( + [self.a1.pk, self.a2.pk] + ) self.assertEqual( arts, { @@ -794,8 +806,12 @@ class LookupTests(TestCase): ), ], ) + # RemovedInDjango70Warning: When the deprecation ends, remove this + # assertion. + with ignore_warnings(category=RemovedInDjango70Warning): + qs = Article.objects.values_list(flat=True) self.assertSequenceEqual( - Article.objects.values_list(flat=True), + qs, [ self.a5.id, self.a6.id, @@ -902,6 +918,22 @@ class LookupTests(TestCase): with self.assertRaises(TypeError): Article.objects.values_list("id", "headline", flat=True) + # RemovedInDjango70Warning: When the deprecation ends, replace with: + # def test_values_list_flat_empty_error(self): + # msg = ( + # "'flat' is not valid when values_list is called with no fields." + # ) + # with self.assertRaisesMessage(TypeError, msg): + # Article.objects.values_list(flat=True) + def test_values_list_flat_empty_warning(self): + msg = ( + "Calling values_list() with no field name and flat=True " + "is deprecated. Pass an explicit field name instead, like " + "'pk'." + ) + with self.assertRaisesMessage(RemovedInDjango70Warning, msg): + Article.objects.values_list(flat=True) + def test_get_next_previous_by(self): # Every DateField and DateTimeField creates get_next_by_FOO() and # get_previous_by_FOO() methods. In the case of identical date values, From 6c82b0bc91fc650891b0b411ac4a5a86cf0cf3e8 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Tue, 30 Sep 2025 16:31:01 -0400 Subject: [PATCH 032/116] Made cosmetic edits to 5.2.7 release notes. --- docs/releases/5.2.7.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/releases/5.2.7.txt b/docs/releases/5.2.7.txt index 90d620a408..11dcff6c7c 100644 --- a/docs/releases/5.2.7.txt +++ b/docs/releases/5.2.7.txt @@ -5,8 +5,8 @@ Django 5.2.7 release notes *October 1, 2025* Django 5.2.7 fixes one security issue with severity "high", one security issue -with severity "low", and several bugs in 5.2.6. Also, the latest string -translations from Transifex are incorporated. +with severity "low", and one bug in 5.2.6. Also, the latest string translations +from Transifex are incorporated. Bugfixes ======== From 41b43c74bda19753c757036673ea9db74acf494a Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Wed, 10 Sep 2025 09:53:52 +0200 Subject: [PATCH 033/116] Fixed CVE-2025-59681 -- Protected QuerySet.annotate(), alias(), aggregate(), and extra() against SQL injection in column aliases on MySQL/MariaDB. Thanks sw0rd1ight for the report. Follow up to 93cae5cb2f9a4ef1514cf1a41f714fef08005200. --- django/db/models/sql/query.py | 17 ++++----- docs/releases/4.2.25.txt | 9 ++++- docs/releases/5.1.13.txt | 9 ++++- docs/releases/5.2.7.txt | 9 +++++ tests/aggregation/tests.py | 4 +-- tests/annotations/tests.py | 43 ++++++++++++----------- tests/expressions/test_queryset_values.py | 8 ++--- tests/queries/tests.py | 4 +-- 8 files changed, 64 insertions(+), 39 deletions(-) diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py index 39ecab2e91..84950d4ec0 100644 --- a/django/db/models/sql/query.py +++ b/django/db/models/sql/query.py @@ -51,12 +51,12 @@ from django.utils.tree import Node __all__ = ["Query", "RawQuery"] # RemovedInDjango70Warning: When the deprecation ends, replace with: -# Quotation marks ('"`[]), whitespace characters, semicolons, percent signs -# or inline SQL comments are forbidden in column aliases. -# FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"\]\[;\s]|%|--|/\*|\*/") -# Quotation marks ('"`[]), whitespace characters, semicolons, or inline +# Quotation marks ('"`[]), whitespace characters, semicolons, percent signs, +# hashes, or inline SQL comments are forbidden in column aliases. +# FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"\]\[;\s]|%|#|--|/\*|\*/") +# Quotation marks ('"`[]), whitespace characters, semicolons, hashes, or inline # SQL comments are forbidden in column aliases. -FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"\]\[;\s]|--|/\*|\*/") +FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"\]\[;\s]|#|--|/\*|\*/") # Inspired from # https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS @@ -1222,11 +1222,12 @@ class Query(BaseExpression): ) if FORBIDDEN_ALIAS_PATTERN.search(alias): raise ValueError( - "Column aliases cannot contain whitespace characters, quotation marks, " + "Column aliases cannot contain whitespace characters, hashes, " # RemovedInDjango70Warning: When the deprecation ends, replace # with: - # "semicolons, percent signs, or SQL comments." - "semicolons, or SQL comments." + # "quotation marks, semicolons, percent signs, or SQL " + # "comments." + "quotation marks, semicolons, or SQL comments." ) def add_annotation(self, annotation, alias, select=True): diff --git a/docs/releases/4.2.25.txt b/docs/releases/4.2.25.txt index 69f238c3c1..5412777055 100644 --- a/docs/releases/4.2.25.txt +++ b/docs/releases/4.2.25.txt @@ -7,4 +7,11 @@ Django 4.2.25 release notes Django 4.2.25 fixes one security issue with severity "high" and one security issue with severity "low" in 4.2.24. -... +CVE-2025-59681: Potential SQL injection in ``QuerySet.annotate()``, ``alias()``, ``aggregate()``, and ``extra()`` on MySQL and MariaDB +====================================================================================================================================== + +:meth:`.QuerySet.annotate`, :meth:`~.QuerySet.alias`, +:meth:`~.QuerySet.aggregate`, and :meth:`~.QuerySet.extra` methods were subject +to SQL injection in column aliases, using a suitably crafted dictionary, with +dictionary expansion, as the ``**kwargs`` passed to these methods (follow up to +:cve:`2022-28346`). diff --git a/docs/releases/5.1.13.txt b/docs/releases/5.1.13.txt index a181694be2..96b81c0102 100644 --- a/docs/releases/5.1.13.txt +++ b/docs/releases/5.1.13.txt @@ -7,4 +7,11 @@ Django 5.1.13 release notes Django 5.1.13 fixes one security issue with severity "high" and one security issue with severity "low" in 5.1.12. -... +CVE-2025-59681: Potential SQL injection in ``QuerySet.annotate()``, ``alias()``, ``aggregate()``, and ``extra()`` on MySQL and MariaDB +====================================================================================================================================== + +:meth:`.QuerySet.annotate`, :meth:`~.QuerySet.alias`, +:meth:`~.QuerySet.aggregate`, and :meth:`~.QuerySet.extra` methods were subject +to SQL injection in column aliases, using a suitably crafted dictionary, with +dictionary expansion, as the ``**kwargs`` passed to these methods (follow up to +:cve:`2022-28346`). diff --git a/docs/releases/5.2.7.txt b/docs/releases/5.2.7.txt index 11dcff6c7c..05d03a991e 100644 --- a/docs/releases/5.2.7.txt +++ b/docs/releases/5.2.7.txt @@ -8,6 +8,15 @@ Django 5.2.7 fixes one security issue with severity "high", one security issue with severity "low", and one bug in 5.2.6. Also, the latest string translations from Transifex are incorporated. +CVE-2025-59681: Potential SQL injection in ``QuerySet.annotate()``, ``alias()``, ``aggregate()``, and ``extra()`` on MySQL and MariaDB +====================================================================================================================================== + +:meth:`.QuerySet.annotate`, :meth:`~.QuerySet.alias`, +:meth:`~.QuerySet.aggregate`, and :meth:`~.QuerySet.extra` methods were subject +to SQL injection in column aliases, using a suitably crafted dictionary, with +dictionary expansion, as the ``**kwargs`` passed to these methods (follow up to +:cve:`2022-28346`). + Bugfixes ======== diff --git a/tests/aggregation/tests.py b/tests/aggregation/tests.py index bd33a532b3..f2ec4bd343 100644 --- a/tests/aggregation/tests.py +++ b/tests/aggregation/tests.py @@ -2244,8 +2244,8 @@ class AggregateTestCase(TestCase): def test_alias_sql_injection(self): crafted_alias = """injected_name" from "aggregation_author"; --""" msg = ( - "Column aliases cannot contain whitespace characters, quotation marks, " - "semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, quotation " + "marks, semicolons, or SQL comments." ) with self.assertRaisesMessage(ValueError, msg): Author.objects.aggregate(**{crafted_alias: Avg("age")}) diff --git a/tests/annotations/tests.py b/tests/annotations/tests.py index cf1eebf8d7..a114480d48 100644 --- a/tests/annotations/tests.py +++ b/tests/annotations/tests.py @@ -1161,12 +1161,12 @@ class NonAggregateAnnotationTestCase(TestCase): crafted_alias = """injected_name" from "annotations_book"; --""" # RemovedInDjango70Warning: When the deprecation ends, replace with: # msg = ( - # "Column aliases cannot contain whitespace characters, quotation " - # "marks, semicolons, percent signs, or SQL comments." + # "Column aliases cannot contain whitespace characters, hashes, " + # "quotation marks, semicolons, percent signs, or SQL comments." # ) msg = ( - "Column aliases cannot contain whitespace characters, quotation marks, " - "semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, quotation " + "marks, semicolons, or SQL comments." ) with self.assertRaisesMessage(ValueError, msg): Book.objects.annotate(**{crafted_alias: Value(1)}) @@ -1175,12 +1175,12 @@ class NonAggregateAnnotationTestCase(TestCase): crafted_alias = """injected_name" from "annotations_book"; --""" # RemovedInDjango70Warning: When the deprecation ends, replace with: # msg = ( - # "Column aliases cannot contain whitespace characters, quotation " - # "marks, semicolons, percent signs, or SQL comments." + # "Column aliases cannot contain whitespace characters, hashes, " + # "quotation marks, semicolons, percent signs, or SQL comments." # ) msg = ( - "Column aliases cannot contain whitespace characters, quotation marks, " - "semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, quotation " + "marks, semicolons, or SQL comments." ) with self.assertRaisesMessage(ValueError, msg): Book.objects.annotate(**{crafted_alias: FilteredRelation("author")}) @@ -1199,18 +1199,19 @@ class NonAggregateAnnotationTestCase(TestCase): "alias;", # RemovedInDjango70Warning: When the deprecation ends, add this: # "alias%", - # [] are used by MSSQL. + # [] and # are used by MSSQL. "alias[", "alias]", + "ali#as", ] # RemovedInDjango70Warning: When the deprecation ends, replace with: # msg = ( - # "Column aliases cannot contain whitespace characters, quotation " - # "marks, semicolons, percent signs, or SQL comments." + # "Column aliases cannot contain whitespace characters, hashes, " + # "quotation marks, semicolons, percent signs, or SQL comments." # ) msg = ( - "Column aliases cannot contain whitespace characters, quotation marks, " - "semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, quotation " + "marks, semicolons, or SQL comments." ) for crafted_alias in tests: with self.subTest(crafted_alias): @@ -1516,12 +1517,12 @@ class AliasTests(TestCase): crafted_alias = """injected_name" from "annotations_book"; --""" # RemovedInDjango70Warning: When the deprecation ends, replace with: # msg = ( - # "Column aliases cannot contain whitespace characters, quotation " - # "marks, semicolons, percent signs, or SQL comments." + # "Column aliases cannot contain whitespace characters, hashes, " + # "quotation marks, semicolons, percent signs, or SQL comments." # ) msg = ( - "Column aliases cannot contain whitespace characters, quotation marks, " - "semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, quotation " + "marks, semicolons, or SQL comments." ) with self.assertRaisesMessage(ValueError, msg): Book.objects.alias(**{crafted_alias: Value(1)}) @@ -1530,12 +1531,12 @@ class AliasTests(TestCase): crafted_alias = """injected_name" from "annotations_book"; --""" # RemovedInDjango70Warning: When the deprecation ends, replace with: # msg = ( - # "Column aliases cannot contain whitespace characters, quotation " - # "marks, semicolons, percent signs, or SQL comments." + # "Column aliases cannot contain whitespace characters, hashes, " + # "quotation marks, semicolons, percent signs, or SQL comments." # ) msg = ( - "Column aliases cannot contain whitespace characters, quotation marks, " - "semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, quotation " + "marks, semicolons, or SQL comments." ) with self.assertRaisesMessage(ValueError, msg): Book.objects.alias(**{crafted_alias: FilteredRelation("authors")}) diff --git a/tests/expressions/test_queryset_values.py b/tests/expressions/test_queryset_values.py index 70e9166655..24f22e8187 100644 --- a/tests/expressions/test_queryset_values.py +++ b/tests/expressions/test_queryset_values.py @@ -44,8 +44,8 @@ class ValuesExpressionsTests(TestCase): def test_values_expression_alias_sql_injection(self): crafted_alias = """injected_name" from "expressions_company"; --""" msg = ( - "Column aliases cannot contain whitespace characters, quotation marks, " - "semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, quotation " + "marks, semicolons, or SQL comments." ) with self.assertRaisesMessage(ValueError, msg): Company.objects.values(**{crafted_alias: F("ceo__salary")}) @@ -54,8 +54,8 @@ class ValuesExpressionsTests(TestCase): def test_values_expression_alias_sql_injection_json_field(self): crafted_alias = """injected_name" from "expressions_company"; --""" msg = ( - "Column aliases cannot contain whitespace characters, quotation marks, " - "semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, quotation " + "marks, semicolons, or SQL comments." ) with self.assertRaisesMessage(ValueError, msg): JSONFieldModel.objects.values(f"data__{crafted_alias}") diff --git a/tests/queries/tests.py b/tests/queries/tests.py index 4158a9a596..4ee4572719 100644 --- a/tests/queries/tests.py +++ b/tests/queries/tests.py @@ -1967,8 +1967,8 @@ class Queries5Tests(TestCase): def test_extra_select_alias_sql_injection(self): crafted_alias = """injected_name" from "queries_note"; --""" msg = ( - "Column aliases cannot contain whitespace characters, quotation marks, " - "semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, quotation " + "marks, semicolons, or SQL comments." ) with self.assertRaisesMessage(ValueError, msg): Note.objects.extra(select={crafted_alias: "1"}) From 924a0c092e65fa2d0953fd1855d2dc8786d94de2 Mon Sep 17 00:00:00 2001 From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:13:36 +0200 Subject: [PATCH 034/116] Fixed CVE-2025-59682 -- Fixed potential partial directory-traversal via archive.extract(). Thanks stackered for the report. Follow up to 05413afa8c18cdb978fcdf470e09f7a12b234a23. --- django/utils/archive.py | 6 +++++- docs/releases/4.2.25.txt | 8 ++++++++ docs/releases/5.1.13.txt | 8 ++++++++ docs/releases/5.2.7.txt | 8 ++++++++ tests/utils_tests/test_archive.py | 19 +++++++++++++++++++ 5 files changed, 48 insertions(+), 1 deletion(-) diff --git a/django/utils/archive.py b/django/utils/archive.py index 4042e89af9..e9c0ce7cb4 100644 --- a/django/utils/archive.py +++ b/django/utils/archive.py @@ -145,7 +145,11 @@ class BaseArchive: def target_filename(self, to_path, name): target_path = os.path.abspath(to_path) filename = os.path.abspath(os.path.join(target_path, name)) - if not filename.startswith(target_path): + try: + if os.path.commonpath([target_path, filename]) != target_path: + raise SuspiciousOperation("Archive contains invalid path: '%s'" % name) + except ValueError: + # Different drives on Windows raises ValueError. raise SuspiciousOperation("Archive contains invalid path: '%s'" % name) return filename diff --git a/docs/releases/4.2.25.txt b/docs/releases/4.2.25.txt index 5412777055..7ba23c0132 100644 --- a/docs/releases/4.2.25.txt +++ b/docs/releases/4.2.25.txt @@ -15,3 +15,11 @@ CVE-2025-59681: Potential SQL injection in ``QuerySet.annotate()``, ``alias()``, to SQL injection in column aliases, using a suitably crafted dictionary, with dictionary expansion, as the ``**kwargs`` passed to these methods (follow up to :cve:`2022-28346`). + +CVE-2025-59682: Potential partial directory-traversal via ``archive.extract()`` +=============================================================================== + +The ``django.utils.archive.extract()`` function, used by +:option:`startapp --template` and :option:`startproject --template`, allowed +partial directory-traversal via an archive with file paths sharing a common +prefix with the target directory (follow up to :cve:`2021-3281`). diff --git a/docs/releases/5.1.13.txt b/docs/releases/5.1.13.txt index 96b81c0102..7b9b5c8d39 100644 --- a/docs/releases/5.1.13.txt +++ b/docs/releases/5.1.13.txt @@ -15,3 +15,11 @@ CVE-2025-59681: Potential SQL injection in ``QuerySet.annotate()``, ``alias()``, to SQL injection in column aliases, using a suitably crafted dictionary, with dictionary expansion, as the ``**kwargs`` passed to these methods (follow up to :cve:`2022-28346`). + +CVE-2025-59682: Potential partial directory-traversal via ``archive.extract()`` +=============================================================================== + +The ``django.utils.archive.extract()`` function, used by +:option:`startapp --template` and :option:`startproject --template`, allowed +partial directory-traversal via an archive with file paths sharing a common +prefix with the target directory (follow up to :cve:`2021-3281`). diff --git a/docs/releases/5.2.7.txt b/docs/releases/5.2.7.txt index 05d03a991e..b8c27d1de2 100644 --- a/docs/releases/5.2.7.txt +++ b/docs/releases/5.2.7.txt @@ -17,6 +17,14 @@ to SQL injection in column aliases, using a suitably crafted dictionary, with dictionary expansion, as the ``**kwargs`` passed to these methods (follow up to :cve:`2022-28346`). +CVE-2025-59682: Potential partial directory-traversal via ``archive.extract()`` +=============================================================================== + +The ``django.utils.archive.extract()`` function, used by +:option:`startapp --template` and :option:`startproject --template`, allowed +partial directory-traversal via an archive with file paths sharing a common +prefix with the target directory (follow up to :cve:`2021-3281`). + Bugfixes ======== diff --git a/tests/utils_tests/test_archive.py b/tests/utils_tests/test_archive.py index 89a45bc072..4d365e4d98 100644 --- a/tests/utils_tests/test_archive.py +++ b/tests/utils_tests/test_archive.py @@ -3,6 +3,7 @@ import stat import sys import tempfile import unittest +import zipfile from django.core.exceptions import SuspiciousOperation from django.test import SimpleTestCase @@ -94,3 +95,21 @@ class TestArchiveInvalid(SimpleTestCase): with self.subTest(entry), tempfile.TemporaryDirectory() as tmpdir: with self.assertRaisesMessage(SuspiciousOperation, msg % invalid_path): archive.extract(os.path.join(archives_dir, entry), tmpdir) + + def test_extract_function_traversal_startswith(self): + with tempfile.TemporaryDirectory() as tmpdir: + base = os.path.abspath(tmpdir) + tarfile_handle = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) + tar_path = tarfile_handle.name + tarfile_handle.close() + self.addCleanup(os.remove, tar_path) + + malicious_member = os.path.join(base + "abc", "evil.txt") + with zipfile.ZipFile(tar_path, "w") as zf: + zf.writestr(malicious_member, "evil\n") + zf.writestr("test.txt", "data\n") + + with self.assertRaisesMessage( + SuspiciousOperation, "Archive contains invalid path" + ): + archive.extract(tar_path, base) From 1324d9037e9281ec0fdd88c15b20881c7a6ea8b9 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Wed, 1 Oct 2025 10:30:45 -0400 Subject: [PATCH 035/116] Added stub release notes for 5.2.8. --- docs/releases/5.2.8.txt | 13 +++++++++++++ docs/releases/index.txt | 1 + 2 files changed, 14 insertions(+) create mode 100644 docs/releases/5.2.8.txt diff --git a/docs/releases/5.2.8.txt b/docs/releases/5.2.8.txt new file mode 100644 index 0000000000..fd35dd6af5 --- /dev/null +++ b/docs/releases/5.2.8.txt @@ -0,0 +1,13 @@ +========================== +Django 5.2.8 release notes +========================== + +*Expected November 5, 2025* + +Django 5.2.8 fixes several bugs in 5.2.7. + +Bugfixes +======== + +* ... + diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 8a2d5c13a4..22c9710db3 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -39,6 +39,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 5.2.8 5.2.7 5.2.6 5.2.5 From 43d84aef04a9e71164c21a74885996981857e66e Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Wed, 1 Oct 2025 10:39:02 -0400 Subject: [PATCH 036/116] Added CVE-2025-59681 and CVE-2025-59682 to security archive. --- docs/releases/security.txt | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/releases/security.txt b/docs/releases/security.txt index 637c4695f0..979e66b04e 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -36,6 +36,30 @@ Issues under Django's security process All security issues have been handled under versions of Django's security process. These are listed below. +October 1, 2025 - :cve:`2025-59681` +----------------------------------- + +Potential SQL injection in ``QuerySet.annotate()``, ``alias()``, ``aggregate()``, and ``extra()`` on MySQL and MariaDB. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <4ceaaee7e04b416fc465e838a6ef43ca0ccffafe>` +* Django 5.2 :commit:`(patch) <52fbae0a4dbbe5faa59827f8f05694a0065cc135>` +* Django 5.1 :commit:`(patch) <01d2d770e22bffe53c7f1e611e2bbca94cb8a2e7>` +* Django 4.2 :commit:`(patch) <38d9ef8c7b5cb6ef51b933e51a20e0e0063f33d5>` + +October 1, 2025 - :cve:`2025-59682` +----------------------------------- + +Potential partial directory-traversal via ``archive.extract()``. +`Full description +`__ + +* Django 6.0 :commit:`(patch) ` +* Django 5.2 :commit:`(patch) ` +* Django 5.1 :commit:`(patch) <74fa85c688a87224637155902bcd738bb9e65e11>` +* Django 4.2 :commit:`(patch) <9504bbaa392c9fe37eee9291f5b4c29eb6037619>` + September 3, 2025 - :cve:`2025-57833` ------------------------------------- From 1499c95d990fb776c39ad60e43228cbbbfcad3a8 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Wed, 1 Oct 2025 21:15:57 +0200 Subject: [PATCH 037/116] Rewrapped security archive at 79 chars. --- docs/releases/security.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/releases/security.txt b/docs/releases/security.txt index 979e66b04e..2bdb75e0cc 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -39,8 +39,8 @@ process. These are listed below. October 1, 2025 - :cve:`2025-59681` ----------------------------------- -Potential SQL injection in ``QuerySet.annotate()``, ``alias()``, ``aggregate()``, and ``extra()`` on MySQL and MariaDB. -`Full description +Potential SQL injection in ``QuerySet.annotate()``, ``alias()``, +``aggregate()``, and ``extra()`` on MySQL and MariaDB. `Full description `__ * Django 6.0 :commit:`(patch) <4ceaaee7e04b416fc465e838a6ef43ca0ccffafe>` From 6cb641ba75b1e6eace9a46e3cbade70e4af2ff66 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Thu, 2 Oct 2025 13:20:01 +0200 Subject: [PATCH 038/116] Refs #36491 -- Skipped ParallelTestSuiteTest.test_buffer_mode_reports_setupclass_failure() without tblib. --- tests/test_runner/test_parallel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_runner/test_parallel.py b/tests/test_runner/test_parallel.py index d4558018b0..fa129da768 100644 --- a/tests/test_runner/test_parallel.py +++ b/tests/test_runner/test_parallel.py @@ -283,6 +283,7 @@ class ParallelTestSuiteTest(SimpleTestCase): self.assertEqual(len(result.errors), 0) self.assertEqual(len(result.failures), 0) + @unittest.skipUnless(tblib is not None, "requires tblib to be installed") def test_buffer_mode_reports_setupclass_failure(self): test = SampleErrorTest("dummy_test") remote_result = RemoteTestResult() From 0a09c60e97166e0188717ff340b4d93b72207e96 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Thu, 25 Sep 2025 09:56:24 -0400 Subject: [PATCH 039/116] Refs #36143, #28596 -- Avoided mentioning exact query parameter limit in bulk_create() docs. --- docs/ref/models/querysets.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 4be1759af2..aee10e55fc 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -2445,8 +2445,8 @@ This has a number of caveats though: Entry.objects.bulk_create(batch, batch_size) The ``batch_size`` parameter controls how many objects are created in a single -query. The default is to create all objects in one batch, except for SQLite -where the default is such that at most 999 variables per query are used. +query. The default is to create as many objects in one batch as the database +will allow. (SQLite and Oracle limit the number of parameters in a query.) On databases that support it (all but Oracle), setting the ``ignore_conflicts`` parameter to ``True`` tells the database to ignore failure to insert any rows From 2514857e3fae831106832cca8823237801cf2cad Mon Sep 17 00:00:00 2001 From: Dani Fornons Date: Fri, 3 Oct 2025 12:20:28 +0200 Subject: [PATCH 040/116] Fixed #36636, Refs #15902 -- Removed session-based storage reference from set_language() docs. --- django/views/i18n.py | 6 +++--- docs/topics/i18n/translation.txt | 7 +++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/django/views/i18n.py b/django/views/i18n.py index 49e3f808c1..a110eb87ef 100644 --- a/django/views/i18n.py +++ b/django/views/i18n.py @@ -29,9 +29,9 @@ def builtin_template_path(name): def set_language(request): """ - Redirect to a given URL while setting the chosen language in the session - (if enabled) and in a cookie. The URL and the language code need to be - specified in the request parameters. + Redirect to a given URL while setting the chosen language in the language + cookie. The URL and the language code need to be specified in the request + parameters. Since this view changes how the user will see the rest of the site, it must only be accessed as a POST request. If called as a GET request, it will diff --git a/docs/topics/i18n/translation.txt b/docs/topics/i18n/translation.txt index 186362ca7e..89dd11e0f4 100644 --- a/docs/topics/i18n/translation.txt +++ b/docs/topics/i18n/translation.txt @@ -1884,10 +1884,9 @@ Activate this view by adding the following line to your URLconf:: language-independent itself to work correctly. The view expects to be called via the ``POST`` method, with a ``language`` -parameter set in request. If session support is enabled, the view saves the -language choice in the user's session. It also saves the language choice in a -cookie that is named ``django_language`` by default. (The name can be changed -through the :setting:`LANGUAGE_COOKIE_NAME` setting.) +parameter set in request. The view saves the language choice in a cookie that +is named ``django_language`` by default. (The name can be changed through the +:setting:`LANGUAGE_COOKIE_NAME` setting.) After setting the language choice, Django looks for a ``next`` parameter in the ``POST`` or ``GET`` data. If that is found and Django considers it to be a safe From 5bd775703c361d05458f1d81684500705d0f51ea Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Fri, 3 Oct 2025 13:17:01 +0200 Subject: [PATCH 041/116] Fixed #36623 -- Dropped support for PostgreSQL 14 and PostGIS 3.1. --- .github/workflows/schedule_tests.yml | 2 +- .github/workflows/selenium.yml | 2 +- django/contrib/gis/db/backends/postgis/operations.py | 2 +- django/db/backends/postgresql/features.py | 11 ++--------- docs/ref/contrib/gis/install/geolibs.txt | 3 +-- docs/ref/contrib/gis/install/index.txt | 8 ++++---- docs/ref/contrib/postgres/aggregates.txt | 2 +- docs/ref/contrib/postgres/constraints.txt | 3 +-- docs/ref/databases.txt | 2 +- docs/ref/models/constraints.txt | 2 +- docs/ref/models/indexes.txt | 5 ++--- docs/releases/6.1.txt | 11 +++++++++++ tests/backends/postgresql/tests.py | 8 ++++---- 13 files changed, 31 insertions(+), 30 deletions(-) diff --git a/.github/workflows/schedule_tests.yml b/.github/workflows/schedule_tests.yml index ed3b6b9428..c4d559ec95 100644 --- a/.github/workflows/schedule_tests.yml +++ b/.github/workflows/schedule_tests.yml @@ -104,7 +104,7 @@ jobs: name: Selenium tests, PostgreSQL services: postgres: - image: postgres:14-alpine + image: postgres:15-alpine env: POSTGRES_DB: django POSTGRES_USER: user diff --git a/.github/workflows/selenium.yml b/.github/workflows/selenium.yml index 9348a3550a..9cb71c9143 100644 --- a/.github/workflows/selenium.yml +++ b/.github/workflows/selenium.yml @@ -43,7 +43,7 @@ jobs: name: PostgreSQL services: postgres: - image: postgres:14-alpine + image: postgres:15-alpine env: POSTGRES_DB: django POSTGRES_USER: user diff --git a/django/contrib/gis/db/backends/postgis/operations.py b/django/contrib/gis/db/backends/postgis/operations.py index df3cc7c7ee..5a403fd7fe 100644 --- a/django/contrib/gis/db/backends/postgis/operations.py +++ b/django/contrib/gis/db/backends/postgis/operations.py @@ -204,7 +204,7 @@ class PostGISOperations(BaseSpatialOperations, DatabaseOperations): raise ImproperlyConfigured( 'Cannot determine PostGIS version for database "%s" ' 'using command "SELECT postgis_lib_version()". ' - "GeoDjango requires at least PostGIS version 3.1. " + "GeoDjango requires at least PostGIS version 3.2. " "Was the database created from a spatial database " "template?" % self.connection.settings_dict["NAME"] ) diff --git a/django/db/backends/postgresql/features.py b/django/db/backends/postgresql/features.py index 23c31c0929..5bbf4b86cb 100644 --- a/django/db/backends/postgresql/features.py +++ b/django/db/backends/postgresql/features.py @@ -7,7 +7,7 @@ from django.utils.functional import cached_property class DatabaseFeatures(BaseDatabaseFeatures): - minimum_database_version = (14,) + minimum_database_version = (15,) allows_group_by_selected_pks = True can_return_columns_from_insert = True can_return_rows_from_bulk_insert = True @@ -67,6 +67,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): supports_update_conflicts_with_target = True supports_covering_indexes = True supports_stored_generated_columns = True + supports_nulls_distinct_unique_constraints = True can_rename_index = True test_collations = { "deterministic": "C", @@ -155,10 +156,6 @@ class DatabaseFeatures(BaseDatabaseFeatures): "PositiveSmallIntegerField": "SmallIntegerField", } - @cached_property - def is_postgresql_15(self): - return self.connection.pg_version >= 150000 - @cached_property def is_postgresql_16(self): return self.connection.pg_version >= 160000 @@ -172,10 +169,6 @@ class DatabaseFeatures(BaseDatabaseFeatures): return self.connection.pg_version >= 180000 supports_unlimited_charfield = True - supports_nulls_distinct_unique_constraints = property( - operator.attrgetter("is_postgresql_15") - ) - supports_any_value = property(operator.attrgetter("is_postgresql_16")) supports_virtual_generated_columns = property( operator.attrgetter("is_postgresql_18") diff --git a/docs/ref/contrib/gis/install/geolibs.txt b/docs/ref/contrib/gis/install/geolibs.txt index f6dba16906..ab9c8a27dc 100644 --- a/docs/ref/contrib/gis/install/geolibs.txt +++ b/docs/ref/contrib/gis/install/geolibs.txt @@ -17,7 +17,7 @@ Program Description Required `PROJ`_ Cartographic Projections library Yes (PostgreSQL and SQLite only) 9.x, 8.x, 7.x, 6.x :ref:`GDAL ` Geospatial Data Abstraction Library Yes 3.11, 3.10, 3.9, 3.8, 3.7, 3.6, 3.5, 3.4, 3.3, 3.2, 3.1 :ref:`GeoIP ` IP-based geolocation library No 2 -`PostGIS`__ Spatial extensions for PostgreSQL Yes (PostgreSQL only) 3.5, 3.4, 3.3, 3.2, 3.1 +`PostGIS`__ Spatial extensions for PostgreSQL Yes (PostgreSQL only) 3.5, 3.4, 3.3, 3.2 `SpatiaLite`__ Spatial extensions for SQLite Yes (SQLite only) 5.1, 5.0, 4.3 ============================== ==================================== ================================ ======================================================= @@ -44,7 +44,6 @@ totally fine with GeoDjango. Your mileage may vary. GDAL 3.9.0 2024-05-10 GDAL 3.10.0 2024-11-06 GDAL 3.11.0 2025-05-09 - PostGIS 3.1.0 2020-12-18 PostGIS 3.2.0 2021-12-18 PostGIS 3.3.0 2022-08-27 PostGIS 3.4.0 2023-08-15 diff --git a/docs/ref/contrib/gis/install/index.txt b/docs/ref/contrib/gis/install/index.txt index 9421723eb8..e08c78b147 100644 --- a/docs/ref/contrib/gis/install/index.txt +++ b/docs/ref/contrib/gis/install/index.txt @@ -57,7 +57,7 @@ supported versions, and any notes for each of the supported database backends: ================== ============================== ================== ========================================= Database Library Requirements Supported Versions Notes ================== ============================== ================== ========================================= -PostgreSQL GEOS, GDAL, PROJ, PostGIS 14+ Requires PostGIS. +PostgreSQL GEOS, GDAL, PROJ, PostGIS 15+ Requires PostGIS. MySQL GEOS, GDAL 8.0.11+ :ref:`Limited functionality `. Oracle GEOS, GDAL 19+ XE not supported. SQLite GEOS, GDAL, PROJ, SpatiaLite 3.31.0+ Requires SpatiaLite 4.3+ @@ -305,7 +305,7 @@ Summary: .. code-block:: shell - $ sudo port install postgresql14-server + $ sudo port install postgresql15-server $ sudo port install geos $ sudo port install proj6 $ sudo port install postgis3 @@ -319,14 +319,14 @@ Summary: .. code-block:: shell - export PATH=/opt/local/bin:/opt/local/lib/postgresql14/bin + export PATH=/opt/local/bin:/opt/local/lib/postgresql15/bin In addition, add the ``DYLD_FALLBACK_LIBRARY_PATH`` setting so that the libraries can be found by Python: .. code-block:: shell - export DYLD_FALLBACK_LIBRARY_PATH=/opt/local/lib:/opt/local/lib/postgresql14 + export DYLD_FALLBACK_LIBRARY_PATH=/opt/local/lib:/opt/local/lib/postgresql15 __ https://www.macports.org/ diff --git a/docs/ref/contrib/postgres/aggregates.txt b/docs/ref/contrib/postgres/aggregates.txt index d2bd48c4fd..1825ec2fc5 100644 --- a/docs/ref/contrib/postgres/aggregates.txt +++ b/docs/ref/contrib/postgres/aggregates.txt @@ -76,7 +76,7 @@ General-purpose aggregation functions .. class:: BitXor(expression, filter=None, default=None, **extra) Returns an ``int`` of the bitwise ``XOR`` of all non-null input values, or - ``default`` if all values are null. It requires PostgreSQL 14+. + ``default`` if all values are null. ``BoolAnd`` ----------- diff --git a/docs/ref/contrib/postgres/constraints.txt b/docs/ref/contrib/postgres/constraints.txt index 4d13eddd24..ee8ef02aa2 100644 --- a/docs/ref/contrib/postgres/constraints.txt +++ b/docs/ref/contrib/postgres/constraints.txt @@ -130,8 +130,7 @@ used for queries that select only included fields (:attr:`~ExclusionConstraint.include`) and filter only by indexed fields (:attr:`~ExclusionConstraint.expressions`). -``include`` is supported for GiST indexes. PostgreSQL 14+ also supports -``include`` for SP-GiST indexes. +``include`` is supported for GiST and SP-GiST indexes. ``violation_error_code`` ------------------------ diff --git a/docs/ref/databases.txt b/docs/ref/databases.txt index 19af564100..cbd0e2feea 100644 --- a/docs/ref/databases.txt +++ b/docs/ref/databases.txt @@ -119,7 +119,7 @@ below for information on how to set up your database correctly. PostgreSQL notes ================ -Django supports PostgreSQL 14 and higher. `psycopg`_ 3.1.12+ or `psycopg2`_ +Django supports PostgreSQL 15 and higher. `psycopg`_ 3.1.12+ or `psycopg2`_ 2.9.9+ is required, though the latest `psycopg`_ 3.1.12+ is recommended. .. note:: diff --git a/docs/ref/models/constraints.txt b/docs/ref/models/constraints.txt index 34be41962b..9a51c5c7a2 100644 --- a/docs/ref/models/constraints.txt +++ b/docs/ref/models/constraints.txt @@ -273,7 +273,7 @@ creates a unique constraint that only allows one row to store a ``NULL`` value in the ``ordering`` column. Unique constraints with ``nulls_distinct`` are ignored for databases besides -PostgreSQL 15+. +PostgreSQL. ``violation_error_code`` ------------------------ diff --git a/docs/ref/models/indexes.txt b/docs/ref/models/indexes.txt index 6046abf029..d2b2430643 100644 --- a/docs/ref/models/indexes.txt +++ b/docs/ref/models/indexes.txt @@ -214,9 +214,8 @@ See the PostgreSQL documentation for more details about `covering indexes`_. .. admonition:: Restrictions on PostgreSQL - PostgreSQL supports covering B-Tree and :class:`GiST indexes - `. PostgreSQL 14+ also supports - covering :class:`SP-GiST indexes + PostgreSQL supports covering B-Tree, :class:`GiST indexes + `, and :class:`SP-GiST indexes `. .. _covering indexes: https://www.postgresql.org/docs/current/indexes-index-only-scans.html diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 97a9d05d9d..5e852785d9 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -248,6 +248,17 @@ backends. database has native support for ``DurationField``, override this method to simply return the value. +:mod:`django.contrib.gis` +------------------------- + +* Support for PostGIS 3.1 is removed. + +Dropped support for PostgreSQL 14 +--------------------------------- + +Upstream support for PostgreSQL 14 ends in November 2026. Django 6.1 supports +PostgreSQL 15 and higher. + Miscellaneous ------------- diff --git a/tests/backends/postgresql/tests.py b/tests/backends/postgresql/tests.py index c5fa17041c..f7e7d1e68c 100644 --- a/tests/backends/postgresql/tests.py +++ b/tests/backends/postgresql/tests.py @@ -549,12 +549,12 @@ class Tests(TestCase): def test_get_database_version(self): new_connection = no_pool_connection() - new_connection.pg_version = 140009 - self.assertEqual(new_connection.get_database_version(), (14, 9)) + new_connection.pg_version = 150009 + self.assertEqual(new_connection.get_database_version(), (15, 9)) - @mock.patch.object(connection, "get_database_version", return_value=(13,)) + @mock.patch.object(connection, "get_database_version", return_value=(14,)) def test_check_database_version_supported(self, mocked_get_database_version): - msg = "PostgreSQL 14 or later is required (found 13)." + msg = "PostgreSQL 15 or later is required (found 14)." with self.assertRaisesMessage(NotSupportedError, msg): connection.check_database_version_supported() self.assertTrue(mocked_get_database_version.called) From 6e3287408e128e03a6ec5d23c17cdc2eee8760c0 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Fri, 3 Oct 2025 19:15:27 +0200 Subject: [PATCH 042/116] Refs #36623 -- Confirmed support for PostGIS 3.6. --- docs/ref/contrib/gis/install/geolibs.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ref/contrib/gis/install/geolibs.txt b/docs/ref/contrib/gis/install/geolibs.txt index ab9c8a27dc..37b4a942f8 100644 --- a/docs/ref/contrib/gis/install/geolibs.txt +++ b/docs/ref/contrib/gis/install/geolibs.txt @@ -17,7 +17,7 @@ Program Description Required `PROJ`_ Cartographic Projections library Yes (PostgreSQL and SQLite only) 9.x, 8.x, 7.x, 6.x :ref:`GDAL ` Geospatial Data Abstraction Library Yes 3.11, 3.10, 3.9, 3.8, 3.7, 3.6, 3.5, 3.4, 3.3, 3.2, 3.1 :ref:`GeoIP ` IP-based geolocation library No 2 -`PostGIS`__ Spatial extensions for PostgreSQL Yes (PostgreSQL only) 3.5, 3.4, 3.3, 3.2 +`PostGIS`__ Spatial extensions for PostgreSQL Yes (PostgreSQL only) 3.6, 3.5, 3.4, 3.3, 3.2 `SpatiaLite`__ Spatial extensions for SQLite Yes (SQLite only) 5.1, 5.0, 4.3 ============================== ==================================== ================================ ======================================================= @@ -48,6 +48,7 @@ totally fine with GeoDjango. Your mileage may vary. PostGIS 3.3.0 2022-08-27 PostGIS 3.4.0 2023-08-15 PostGIS 3.5.0 2024-09-25 + PostGIS 3.6.0 2025-09-02 PROJ 9.0.0 2022-03-01 PROJ 8.0.0 2021-03-01 PROJ 8.0.0 2021-03-01 From dfb04d94723b5b1e4d552a53b5bb328a7c6ca517 Mon Sep 17 00:00:00 2001 From: Tim Kamanin Date: Tue, 7 Oct 2025 13:08:17 +0200 Subject: [PATCH 043/116] Added required "issue_message" input to "New contributor" GitHub action. --- .github/workflows/new_contributor_pr.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/new_contributor_pr.yml b/.github/workflows/new_contributor_pr.yml index 69f637bfac..163348595d 100644 --- a/.github/workflows/new_contributor_pr.yml +++ b/.github/workflows/new_contributor_pr.yml @@ -15,6 +15,10 @@ jobs: - uses: actions/first-interaction@v3 with: repo_token: ${{ secrets.GITHUB_TOKEN }} + issue_message: | + Hello! Thank you for your interest in Django 💪 + + Django issues are tracked in [Trac](https://code.djangoproject.com/) and not in this repo. pr_message: | Hello! Thank you for your contribution 💪 From 4a8ca8bd6906b705c4445bc915d71beda2fc4b84 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Wed, 8 Oct 2025 10:58:59 +0200 Subject: [PATCH 044/116] Added missing backticks in docs/ref/models/fields.txt. --- docs/ref/models/fields.txt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 9a14d4ceb3..5988d5dc06 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1720,8 +1720,9 @@ The possible values for :attr:`~ForeignKey.on_delete` are found in * .. attribute:: CASCADE - Cascade deletes. Django emulates the behavior of the SQL constraint ON - DELETE CASCADE and also deletes the object containing the ForeignKey. + Cascade deletes. Django emulates the behavior of the SQL constraint ``ON + DELETE CASCADE`` and also deletes the object containing the + :class:`ForeignKey`. :meth:`.Model.delete` isn't called on related models, but the :data:`~django.db.models.signals.pre_delete` and @@ -1940,9 +1941,9 @@ The possible values for :attr:`~ForeignKey.on_delete` are found in Setting it to ``False`` does not mean you can reference a swappable model even if it is swapped out - ``False`` means that the migrations made - with this ForeignKey will always reference the exact model you specify - (so it will fail hard if the user tries to run with a User model you don't - support, for example). + with this :class:`ForeignKey` will always reference the exact model you + specify (so it will fail hard if the user tries to run with a ``User`` + model you don't support, for example). If in doubt, leave it to its default of ``True``. From d514ca6c4e63d1631d186cccaafbc811afd48436 Mon Sep 17 00:00:00 2001 From: Chris Muthig Date: Tue, 7 Oct 2025 09:11:36 -0600 Subject: [PATCH 045/116] Refs #36595 -- Extended "postgis" GitHub Action to run against PostGIS 3.6. Changed image exercise these versions: * latest: POSTGIS="3.5.2 dea6d0a" PGSQL="170" GEOS="3.9.0-CAPI-1.16.2" PROJ="7.2.1" * 17-master: POSTGIS="3.7.0dev 3.6.0rc2-55-gfda22140e" PGSQL="170" GEOS="3.15.0dev-CAPI-1.21.0" PROJ="9.8.0" * 18-3.6-alpine: POSTGIS="3.6.0 0" PGSQL="180" GEOS="3.13.1-CAPI-1.19.2" PROJ="9.6.0" --- .github/workflows/postgis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/postgis.yml b/.github/workflows/postgis.yml index c19b19b8a4..1df146cd3e 100644 --- a/.github/workflows/postgis.yml +++ b/.github/workflows/postgis.yml @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - postgis-version: [latest, "17-3.5-alpine", "17-master"] + postgis-version: ["latest", "18-3.6-alpine", "17-master"] name: PostGIS ${{ matrix.postgis-version }} services: postgres: From 96a7a652166bece8acc96d6335ebb8091de2f496 Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Fri, 6 Dec 2024 20:54:41 +0100 Subject: [PATCH 046/116] Fixed #35961 -- Migrated license metadata in pyproject.toml to conform PEP 639. See https://peps.python.org/pep-0639/ and https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license-and-license-files. Co-authored-by: Jacob Walls --- docs/intro/reusable-apps.txt | 4 ++-- pyproject.toml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/intro/reusable-apps.txt b/docs/intro/reusable-apps.txt index 6c953a4043..c82a2b456e 100644 --- a/docs/intro/reusable-apps.txt +++ b/docs/intro/reusable-apps.txt @@ -208,7 +208,7 @@ this. For a small app like polls, this process isn't too difficult. :caption: ``django-polls/pyproject.toml`` [build-system] - requires = ["setuptools>=69.3"] + requires = ["setuptools>=77.0.3"] build-backend = "setuptools.build_meta" [project] @@ -219,6 +219,7 @@ this. For a small app like polls, this process isn't too difficult. ] description = "A Django app to conduct web-based polls." readme = "README.rst" + license = "BSD-3-Clause" requires-python = ">= 3.12" authors = [ {name = "Your Name", email = "yourname@example.com"}, @@ -228,7 +229,6 @@ this. For a small app like polls, this process isn't too difficult. "Framework :: Django", "Framework :: Django :: X.Y", # Replace "X.Y" as appropriate "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", diff --git a/pyproject.toml b/pyproject.toml index 5b15d37116..3fc10a0131 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=75.8.1"] +requires = ["setuptools>=77.0.3"] build-backend = "setuptools.build_meta" [project] @@ -16,13 +16,13 @@ authors = [ ] description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." readme = "README.rst" -license = {text = "BSD-3-Clause"} +license = "BSD-3-Clause" +license-files = ["LICENSE", "LICENSE.python"] classifiers = [ "Development Status :: 2 - Pre-Alpha", "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", From 608d3ebc8889863d43be1090d634b9507fe4a85e Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Thu, 28 Aug 2025 17:19:20 -0300 Subject: [PATCH 047/116] Fixed #36526 -- Doc'd QuerySet.bulk_update() memory usage when batching. Thanks Simon Charette for the review. --- docs/ref/models/querysets.txt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index aee10e55fc..f290970d2c 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -2506,6 +2506,21 @@ them, but it has a few caveats: * If updating a large number of columns in a large number of rows, the SQL generated can be very large. Avoid this by specifying a suitable ``batch_size``. +* When updating a large number of objects, be aware that ``bulk_update()`` + prepares all of the ``WHEN`` clauses for every object across all batches + before executing any queries. This can require more memory than expected. To + reduce memory usage, you can use an approach like this:: + + from itertools import islice + + batch_size = 100 + ids_iter = range(1000) + while ids := list(islice(ids_iter, batch_size)): + batch = Entry.objects.filter(ids__in=ids) + for entry in batch: + entry.headline = f"Updated headline {entry.pk}" + Entry.objects.bulk_update(batch, ["headline"], batch_size=batch_size) + * Updating fields defined on multi-table inheritance ancestors will incur an extra query per ancestor. * When an individual batch contains duplicates, only the first instance in that From 1167cd1d639c3fee69dbdef351d31e8a17d1fedf Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Thu, 9 Oct 2025 20:01:31 +0200 Subject: [PATCH 048/116] Corrected admin check IDs in docs. --- docs/ref/checks.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index e1ea5bc753..ab92220ac9 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -827,11 +827,11 @@ The following checks are performed on any :class:`~django.contrib.contenttypes.admin.GenericInlineModelAdmin` that is registered as an inline on a :class:`~django.contrib.admin.ModelAdmin`. -* **admin.E301**: ``'ct_field'`` references ``
{% endblock %} {% endif %} diff --git a/django/contrib/admin/templates/admin/auth/user/change_password.html b/django/contrib/admin/templates/admin/auth/user/change_password.html index 01c357d8c7..08d191b765 100644 --- a/django/contrib/admin/templates/admin/auth/user/change_password.html +++ b/django/contrib/admin/templates/admin/auth/user/change_password.html @@ -11,13 +11,13 @@ {% block bodyclass %}{{ block.super }} {{ opts.app_label }}-{{ opts.model_name }} change-form{% endblock %} {% if not is_popup %} {% block breadcrumbs %} - + {% endblock %} {% endif %} {% block content %}
diff --git a/django/contrib/admin/templates/admin/base.html b/django/contrib/admin/templates/admin/base.html index ac72935f29..16c1808a0c 100644 --- a/django/contrib/admin/templates/admin/base.html +++ b/django/contrib/admin/templates/admin/base.html @@ -72,10 +72,10 @@ {% block nav-breadcrumbs %} {% endblock %} diff --git a/django/contrib/admin/templates/admin/change_form.html b/django/contrib/admin/templates/admin/change_form.html index 8e7ced9a48..f6edffb4d4 100644 --- a/django/contrib/admin/templates/admin/change_form.html +++ b/django/contrib/admin/templates/admin/change_form.html @@ -15,12 +15,12 @@ {% if not is_popup %} {% block breadcrumbs %} - + {% endblock %} {% endif %} diff --git a/django/contrib/admin/templates/admin/change_list.html b/django/contrib/admin/templates/admin/change_list.html index b12f8ec583..42e157a85e 100644 --- a/django/contrib/admin/templates/admin/change_list.html +++ b/django/contrib/admin/templates/admin/change_list.html @@ -29,11 +29,11 @@ {% if not is_popup %} {% block breadcrumbs %} - + {% endblock %} {% endif %} diff --git a/django/contrib/admin/templates/admin/delete_confirmation.html b/django/contrib/admin/templates/admin/delete_confirmation.html index 2ccf719e05..1d04008cc0 100644 --- a/django/contrib/admin/templates/admin/delete_confirmation.html +++ b/django/contrib/admin/templates/admin/delete_confirmation.html @@ -10,13 +10,13 @@ {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation{% endblock %} {% block breadcrumbs %} - + {% endblock %} {% block content %} diff --git a/django/contrib/admin/templates/admin/delete_selected_confirmation.html b/django/contrib/admin/templates/admin/delete_selected_confirmation.html index 2414e79095..c6d0566883 100644 --- a/django/contrib/admin/templates/admin/delete_selected_confirmation.html +++ b/django/contrib/admin/templates/admin/delete_selected_confirmation.html @@ -10,12 +10,12 @@ {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation delete-selected-confirmation{% endblock %} {% block breadcrumbs %} - + {% endblock %} {% block content %} diff --git a/django/contrib/admin/templates/admin/invalid_setup.html b/django/contrib/admin/templates/admin/invalid_setup.html index 1ef7c71434..ed686f7e30 100644 --- a/django/contrib/admin/templates/admin/invalid_setup.html +++ b/django/contrib/admin/templates/admin/invalid_setup.html @@ -2,10 +2,10 @@ {% load i18n %} {% block breadcrumbs %} - + {% endblock %} {% block content %} diff --git a/django/contrib/admin/templates/admin/object_history.html b/django/contrib/admin/templates/admin/object_history.html index 40aaecc40a..130232666f 100644 --- a/django/contrib/admin/templates/admin/object_history.html +++ b/django/contrib/admin/templates/admin/object_history.html @@ -2,13 +2,13 @@ {% load i18n admin_urls %} {% block breadcrumbs %} - + {% endblock %} {% block content %} diff --git a/django/contrib/admin/templates/registration/logged_out.html b/django/contrib/admin/templates/registration/logged_out.html index e9a5545024..759ec734c7 100644 --- a/django/contrib/admin/templates/registration/logged_out.html +++ b/django/contrib/admin/templates/registration/logged_out.html @@ -1,7 +1,12 @@ {% extends "admin/base_site.html" %} {% load i18n %} -{% block breadcrumbs %}{% endblock %} +{% block breadcrumbs %} + +{% endblock %} {% block nav-sidebar %}{% endblock %} diff --git a/django/contrib/admin/templates/registration/password_change_done.html b/django/contrib/admin/templates/registration/password_change_done.html index 784ab37278..e8e019fca9 100644 --- a/django/contrib/admin/templates/registration/password_change_done.html +++ b/django/contrib/admin/templates/registration/password_change_done.html @@ -9,10 +9,10 @@ {% include "admin/color_theme_toggle.html" %} {% endblock %} {% block breadcrumbs %} - + {% endblock %} {% block content %} diff --git a/django/contrib/admin/templates/registration/password_change_form.html b/django/contrib/admin/templates/registration/password_change_form.html index 3d66aeb162..d377c201ce 100644 --- a/django/contrib/admin/templates/registration/password_change_form.html +++ b/django/contrib/admin/templates/registration/password_change_form.html @@ -12,10 +12,10 @@ {% include "admin/color_theme_toggle.html" %} {% endblock %} {% block breadcrumbs %} - + {% endblock %} {% block content %}
diff --git a/django/contrib/admin/templates/registration/password_reset_complete.html b/django/contrib/admin/templates/registration/password_reset_complete.html index e6a383fcfe..1e30fdf4b8 100644 --- a/django/contrib/admin/templates/registration/password_reset_complete.html +++ b/django/contrib/admin/templates/registration/password_reset_complete.html @@ -2,10 +2,10 @@ {% load i18n %} {% block breadcrumbs %} - + {% endblock %} {% block content %} diff --git a/django/contrib/admin/templates/registration/password_reset_confirm.html b/django/contrib/admin/templates/registration/password_reset_confirm.html index 5e1478be83..2ad675da24 100644 --- a/django/contrib/admin/templates/registration/password_reset_confirm.html +++ b/django/contrib/admin/templates/registration/password_reset_confirm.html @@ -4,10 +4,10 @@ {% block title %}{% if form.new_password1.errors or form.new_password2.errors %}{% translate "Error:" %} {% endif %}{{ block.super }}{% endblock %} {% block extrastyle %}{{ block.super }}{% endblock %} {% block breadcrumbs %} - + {% endblock %} {% block content %} diff --git a/django/contrib/admin/templates/registration/password_reset_done.html b/django/contrib/admin/templates/registration/password_reset_done.html index 8b1971a76e..194217638f 100644 --- a/django/contrib/admin/templates/registration/password_reset_done.html +++ b/django/contrib/admin/templates/registration/password_reset_done.html @@ -2,10 +2,10 @@ {% load i18n %} {% block breadcrumbs %} - + {% endblock %} {% block content %} diff --git a/django/contrib/admin/templates/registration/password_reset_form.html b/django/contrib/admin/templates/registration/password_reset_form.html index e12189af54..3737414d81 100644 --- a/django/contrib/admin/templates/registration/password_reset_form.html +++ b/django/contrib/admin/templates/registration/password_reset_form.html @@ -4,10 +4,10 @@ {% block title %}{% if form.email.errors %}{% translate "Error:" %} {% endif %}{{ block.super }}{% endblock %} {% block extrastyle %}{{ block.super }}{% endblock %} {% block breadcrumbs %} - + {% endblock %} {% block content %} diff --git a/django/contrib/admindocs/templates/admin_doc/bookmarklets.html b/django/contrib/admindocs/templates/admin_doc/bookmarklets.html index 04b329e6e3..598827ef19 100644 --- a/django/contrib/admindocs/templates/admin_doc/bookmarklets.html +++ b/django/contrib/admindocs/templates/admin_doc/bookmarklets.html @@ -2,11 +2,11 @@ {% load i18n %} {% block breadcrumbs %} - + {% endblock %} {% block title %}{% translate "Documentation bookmarklets" %}{% endblock %} diff --git a/django/contrib/admindocs/templates/admin_doc/index.html b/django/contrib/admindocs/templates/admin_doc/index.html index 1b95a210b3..e602255b32 100644 --- a/django/contrib/admindocs/templates/admin_doc/index.html +++ b/django/contrib/admindocs/templates/admin_doc/index.html @@ -2,10 +2,10 @@ {% load i18n %} {% block breadcrumbs %} - + {% endblock %} {% block title %}{% translate 'Documentation' %}{% endblock %} diff --git a/django/contrib/admindocs/templates/admin_doc/missing_docutils.html b/django/contrib/admindocs/templates/admin_doc/missing_docutils.html index 56c063b129..9bbf76f6c2 100644 --- a/django/contrib/admindocs/templates/admin_doc/missing_docutils.html +++ b/django/contrib/admindocs/templates/admin_doc/missing_docutils.html @@ -2,10 +2,10 @@ {% load i18n %} {% block breadcrumbs %} - + {% endblock %} {% block title %}{% translate 'Please install docutils' %}{% endblock %} diff --git a/django/contrib/admindocs/templates/admin_doc/model_detail.html b/django/contrib/admindocs/templates/admin_doc/model_detail.html index 1cbde0e44a..6cf05d8f1b 100644 --- a/django/contrib/admindocs/templates/admin_doc/model_detail.html +++ b/django/contrib/admindocs/templates/admin_doc/model_detail.html @@ -10,12 +10,12 @@ {% endblock %} {% block breadcrumbs %} - + {% endblock %} {% block title %}{% blocktranslate %}Model: {{ name }}{% endblocktranslate %}{% endblock %} diff --git a/django/contrib/admindocs/templates/admin_doc/model_index.html b/django/contrib/admindocs/templates/admin_doc/model_index.html index b3ecb7ce9c..590cb34b95 100644 --- a/django/contrib/admindocs/templates/admin_doc/model_index.html +++ b/django/contrib/admindocs/templates/admin_doc/model_index.html @@ -4,11 +4,11 @@ {% block coltype %}colSM{% endblock %} {% block breadcrumbs %} - + {% endblock %} {% block title %}{% translate 'Models' %}{% endblock %} diff --git a/django/contrib/admindocs/templates/admin_doc/template_detail.html b/django/contrib/admindocs/templates/admin_doc/template_detail.html index 3dadaa631a..7dae783edb 100644 --- a/django/contrib/admindocs/templates/admin_doc/template_detail.html +++ b/django/contrib/admindocs/templates/admin_doc/template_detail.html @@ -2,12 +2,12 @@ {% load i18n %} {% block breadcrumbs %} - + {% endblock %} {% block title %}{% blocktranslate %}Template: {{ name }}{% endblocktranslate %}{% endblock %} diff --git a/django/contrib/admindocs/templates/admin_doc/template_filter_index.html b/django/contrib/admindocs/templates/admin_doc/template_filter_index.html index c66b7ed3d3..5e9aa2ff9b 100644 --- a/django/contrib/admindocs/templates/admin_doc/template_filter_index.html +++ b/django/contrib/admindocs/templates/admin_doc/template_filter_index.html @@ -3,11 +3,11 @@ {% block coltype %}colSM{% endblock %} {% block breadcrumbs %} - + {% endblock %} {% block title %}{% translate 'Template filters' %}{% endblock %} diff --git a/django/contrib/admindocs/templates/admin_doc/template_tag_index.html b/django/contrib/admindocs/templates/admin_doc/template_tag_index.html index 17b930e337..aa36016dd4 100644 --- a/django/contrib/admindocs/templates/admin_doc/template_tag_index.html +++ b/django/contrib/admindocs/templates/admin_doc/template_tag_index.html @@ -3,11 +3,11 @@ {% block coltype %}colSM{% endblock %} {% block breadcrumbs %} - + {% endblock %} {% block title %}{% translate 'Template tags' %}{% endblock %} diff --git a/django/contrib/admindocs/templates/admin_doc/view_detail.html b/django/contrib/admindocs/templates/admin_doc/view_detail.html index 5a5b47247e..a9dd9a5ecc 100644 --- a/django/contrib/admindocs/templates/admin_doc/view_detail.html +++ b/django/contrib/admindocs/templates/admin_doc/view_detail.html @@ -2,12 +2,12 @@ {% load i18n %} {% block breadcrumbs %} - + {% endblock %} {% block title %}{% blocktranslate %}View: {{ name }}{% endblocktranslate %}{% endblock %} diff --git a/django/contrib/admindocs/templates/admin_doc/view_index.html b/django/contrib/admindocs/templates/admin_doc/view_index.html index 873303f2b2..e754fdf587 100644 --- a/django/contrib/admindocs/templates/admin_doc/view_index.html +++ b/django/contrib/admindocs/templates/admin_doc/view_index.html @@ -3,11 +3,11 @@ {% block coltype %}colSM{% endblock %} {% block breadcrumbs %} - + {% endblock %} {% block title %}{% translate 'Views' %}{% endblock %} diff --git a/tests/admin_views/test_nav_sidebar.py b/tests/admin_views/test_nav_sidebar.py index 1875a2f7a1..74fd648ab0 100644 --- a/tests/admin_views/test_nav_sidebar.py +++ b/tests/admin_views/test_nav_sidebar.py @@ -93,7 +93,6 @@ class AdminSidebarTests(TestCase): ) # Does not include aria-current attribute. self.assertContains(response, 'Users' % url) - self.assertNotContains(response, "aria-current") @override_settings(DEBUG=True) def test_included_app_list_template_context_fully_set(self): diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index ad37416cbf..868b616d76 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -2579,7 +2579,7 @@ class AdminViewPermissionsTest(TestCase): # Add user may login and POST to add view, then redirect to admin root self.client.force_login(self.adduser) addpage = self.client.get(reverse("admin:admin_views_article_add")) - change_list_link = '› Articles' % reverse( + change_list_link = 'Articles' % reverse( "admin:admin_views_article_changelist" ) self.assertNotContains( diff --git a/tests/auth_tests/test_views.py b/tests/auth_tests/test_views.py index 23dc858ddd..a3863b6233 100644 --- a/tests/auth_tests/test_views.py +++ b/tests/auth_tests/test_views.py @@ -1548,7 +1548,9 @@ class ChangelistTests(MessagesTestMixin, AuthViewsTestCase): ) # Breadcrumb. self.assertContains( - response, f"{self.admin.username}\n› Change password" + response, + f'{self.admin.username}\n
  • ' + "Change password
  • ", ) # Usable password field. self.assertContains( @@ -1648,7 +1650,9 @@ class ChangelistTests(MessagesTestMixin, AuthViewsTestCase): self.assertContains(response, f"

    Set password: {test_user.username}

    ") # Breadcrumb. self.assertContains( - response, f"{test_user.username}\n› Set password" + response, + f'{test_user.username}\n
  • ' + "Set password
  • ", ) # Submit buttons self.assertContains(response, ' Date: Sun, 13 Jul 2025 20:01:02 +0200 Subject: [PATCH 060/116] Refs #31223 -- Added __class_getitem__() to SetPasswordMixin. --- django/contrib/auth/forms.py | 3 +++ tests/auth_tests/test_forms.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py index 2214e134d0..aff0cca342 100644 --- a/django/contrib/auth/forms.py +++ b/django/contrib/auth/forms.py @@ -134,6 +134,9 @@ class SetPasswordMixin: user.save() return user + def __class_getitem__(cls, *args, **kwargs): + return cls + class SetUnusablePasswordMixin: """ diff --git a/tests/auth_tests/test_forms.py b/tests/auth_tests/test_forms.py index be55c4369b..73065adddf 100644 --- a/tests/auth_tests/test_forms.py +++ b/tests/auth_tests/test_forms.py @@ -350,6 +350,9 @@ class BaseUserCreationFormTest(TestDataMixin, TestCase): form.fields[field_name].widget.attrs["autocomplete"], autocomplete ) + def test_user_creation_form_class_getitem(self): + self.assertIs(BaseUserCreationForm["MyCustomUser"], BaseUserCreationForm) + class CustomUserCreationFormTest(TestDataMixin, TestCase): From 5d6c36d8349be277bbfab282a1b4aa98296d4842 Mon Sep 17 00:00:00 2001 From: Augusto Pontes <101043200+RankracerBR@users.noreply.github.com> Date: Tue, 14 Oct 2025 09:38:41 -0300 Subject: [PATCH 061/116] Fixed #36654 -- Corrected Model._do_update()'s docstring. --- django/db/models/base.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/django/db/models/base.py b/django/db/models/base.py index d1321d6540..fd51052d01 100644 --- a/django/db/models/base.py +++ b/django/db/models/base.py @@ -1182,8 +1182,9 @@ class Model(AltersData, metaclass=ModelBase): returning_fields, ): """ - Try to update the model. Return True if the model was updated (if an - update query was done and a matching row was found in the DB). + Try to update the model. Return a list of updated fields if the model + was updated (if an update query was done and a matching row was + found in the DB). """ filtered = base_qs.filter(pk=pk_val) if not values: From 19101158070429c8d314926a67ec22a88220f316 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Mon, 13 Oct 2025 14:30:33 -0400 Subject: [PATCH 062/116] Removed mention of setuptools in docs/internals/contributing/writing-code/unit-tests.txt. --- docs/internals/contributing/writing-code/unit-tests.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt index b4bc429af5..974b4861d5 100644 --- a/docs/internals/contributing/writing-code/unit-tests.txt +++ b/docs/internals/contributing/writing-code/unit-tests.txt @@ -325,7 +325,6 @@ dependencies: * :pypi:`PyYAML` 6.0.2+ * :pypi:`pywatchman` * :pypi:`redis` 5.1.0+ -* :pypi:`setuptools` * :pypi:`pymemcache`, plus a `supported Python binding `_ * `gettext `_ From 42758f882dc74059c562ef8ae3da01fd06cf0e0d Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Mon, 13 Oct 2025 14:32:04 -0400 Subject: [PATCH 063/116] Removed setuptools from GitHub actions. Follow-up to 61c5c3173281b1e906a891aa6a6c6f9cdc9f2b8a. --- .github/workflows/postgis.yml | 2 +- .github/workflows/python_matrix.yml | 2 +- .github/workflows/schedule_tests.yml | 10 +++++----- .github/workflows/screenshots.yml | 2 +- .github/workflows/selenium.yml | 4 ++-- .github/workflows/tests.yml | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/postgis.yml b/.github/workflows/postgis.yml index 1df146cd3e..7976fdc03d 100644 --- a/.github/workflows/postgis.yml +++ b/.github/workflows/postgis.yml @@ -59,7 +59,7 @@ jobs: run: | PGPASSWORD=$POSTGRES_PASSWORD psql -U $POSTGRES_USER -d $POSTGRES_DB -h localhost -c "SELECT PostGIS_full_version();" - name: Install and upgrade packaging tools - run: python -m pip install --upgrade pip setuptools wheel + run: python -m pip install --upgrade pip wheel - run: python -m pip install -r tests/requirements/py3.txt -r tests/requirements/postgres.txt -e . - name: Create PostgreSQL settings file run: mv ./.github/workflows/data/test_postgis.py.tpl ./tests/test_postgis.py diff --git a/.github/workflows/python_matrix.yml b/.github/workflows/python_matrix.yml index 072bf1cdbc..bbdb4458b4 100644 --- a/.github/workflows/python_matrix.yml +++ b/.github/workflows/python_matrix.yml @@ -46,7 +46,7 @@ jobs: - name: Install libmemcached-dev for pylibmc run: sudo apt-get install libmemcached-dev - name: Install and upgrade packaging tools - run: python -m pip install --upgrade pip setuptools wheel + run: python -m pip install --upgrade pip wheel - run: python -m pip install -r tests/requirements/py3.txt -e . - name: Run tests run: python -Wall tests/runtests.py -v2 diff --git a/.github/workflows/schedule_tests.yml b/.github/workflows/schedule_tests.yml index c4d559ec95..6ac72e24bb 100644 --- a/.github/workflows/schedule_tests.yml +++ b/.github/workflows/schedule_tests.yml @@ -31,7 +31,7 @@ jobs: cache: 'pip' cache-dependency-path: 'tests/requirements/py3.txt' - name: Install and upgrade packaging tools - run: python -m pip install --upgrade pip setuptools wheel + run: python -m pip install --upgrade pip wheel - run: python -m pip install -r tests/requirements/py3.txt -e . - name: Run tests run: python -Wall tests/runtests.py -v2 @@ -50,7 +50,7 @@ jobs: - name: Install libmemcached-dev for pylibmc run: sudo apt-get install libmemcached-dev - name: Install and upgrade packaging tools - run: python -m pip install --upgrade pip setuptools wheel + run: python -m pip install --upgrade pip wheel - run: python -m pip install . - name: Prepare site-packages run: | @@ -92,7 +92,7 @@ jobs: - name: Install libmemcached-dev for pylibmc run: sudo apt-get install libmemcached-dev - name: Install and upgrade packaging tools - run: python -m pip install --upgrade pip setuptools wheel + run: python -m pip install --upgrade pip wheel - run: python -m pip install -r tests/requirements/py3.txt -e . - name: Run Selenium tests working-directory: ./tests/ @@ -128,7 +128,7 @@ jobs: - name: Install libmemcached-dev for pylibmc run: sudo apt-get install libmemcached-dev - name: Install and upgrade packaging tools - run: python -m pip install --upgrade pip setuptools wheel + run: python -m pip install --upgrade pip wheel - run: python -m pip install -r tests/requirements/py3.txt -r tests/requirements/postgres.txt -e . - name: Create PostgreSQL settings file run: mv ./.github/workflows/data/test_postgres.py.tpl ./tests/test_postgres.py @@ -173,7 +173,7 @@ jobs: - name: Install libmemcached-dev for pylibmc run: sudo apt-get install libmemcached-dev - name: Install and upgrade packaging tools - run: python -m pip install --upgrade pip setuptools wheel + run: python -m pip install --upgrade pip wheel - run: python -m pip install -r tests/requirements/py3.txt -r tests/requirements/postgres.txt -e . - name: Create PostgreSQL settings file run: mv ./.github/workflows/data/test_postgres.py.tpl ./tests/test_postgres.py diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index c701236630..c29cfc9eed 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -28,7 +28,7 @@ jobs: cache: 'pip' cache-dependency-path: 'tests/requirements/py3.txt' - name: Install and upgrade packaging tools - run: python -m pip install --upgrade pip setuptools wheel + run: python -m pip install --upgrade pip wheel - run: python -m pip install -r tests/requirements/py3.txt -e . - name: Run Selenium tests with screenshots diff --git a/.github/workflows/selenium.yml b/.github/workflows/selenium.yml index 9cb71c9143..c5e22d70c3 100644 --- a/.github/workflows/selenium.yml +++ b/.github/workflows/selenium.yml @@ -30,7 +30,7 @@ jobs: - name: Install libmemcached-dev for pylibmc run: sudo apt-get install libmemcached-dev - name: Install and upgrade packaging tools - run: python -m pip install --upgrade pip setuptools wheel + run: python -m pip install --upgrade pip wheel - run: python -m pip install -r tests/requirements/py3.txt -e . - name: Run Selenium tests working-directory: ./tests/ @@ -67,7 +67,7 @@ jobs: - name: Install libmemcached-dev for pylibmc run: sudo apt-get install libmemcached-dev - name: Install and upgrade packaging tools - run: python -m pip install --upgrade pip setuptools wheel + run: python -m pip install --upgrade pip wheel - run: python -m pip install -r tests/requirements/py3.txt -r tests/requirements/postgres.txt -e . - name: Create PostgreSQL settings file run: mv ./.github/workflows/data/test_postgres.py.tpl ./tests/test_postgres.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d293f4dd0a..eb0966e7a2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,7 +35,7 @@ jobs: cache: 'pip' cache-dependency-path: 'tests/requirements/py3.txt' - name: Install and upgrade packaging tools - run: python -m pip install --upgrade pip setuptools wheel + run: python -m pip install --upgrade pip wheel - run: python -m pip install -r tests/requirements/py3.txt -e . - name: Run tests run: python -Wall tests/runtests.py -v2 From cc9df52666b90e2e6fdebd2213493c1c396e804a Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Tue, 14 Oct 2025 08:46:14 -0400 Subject: [PATCH 064/116] Removed pre-release wheel-only advice in docs/internals/howto-release-django.txt. The practice since 2.2a1 (2019) has been to upload source distributions as well. --- docs/internals/howto-release-django.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/internals/howto-release-django.txt b/docs/internals/howto-release-django.txt index a76651934a..4a8dd6483c 100644 --- a/docs/internals/howto-release-django.txt +++ b/docs/internals/howto-release-django.txt @@ -604,8 +604,7 @@ Now you're ready to actually put the release out there. To do this: __ https://djangoci.com/job/confirm-release/ -#. Upload the release packages to PyPI (for pre-releases, only upload the wheel - file): +#. Upload the release packages to PyPI: .. code-block:: shell From 02eed4f37879b2077496f86bb1378a076b981233 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Mon, 13 Oct 2025 17:22:17 -0400 Subject: [PATCH 065/116] Fixed #36648, Refs #33772 -- Accounted for composite pks in first()/last() when aggregating. --- django/db/models/query.py | 10 ++++++++-- docs/releases/5.2.8.txt | 4 ++++ tests/composite_pk/test_aggregate.py | 20 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/django/db/models/query.py b/django/db/models/query.py index b404fd1875..39cc9b6cb3 100644 --- a/django/db/models/query.py +++ b/django/db/models/query.py @@ -2141,8 +2141,14 @@ class QuerySet(AltersData): raise TypeError(f"Cannot use {operator_} operator with combined queryset.") def _check_ordering_first_last_queryset_aggregation(self, method): - if isinstance(self.query.group_by, tuple) and not any( - col.output_field is self.model._meta.pk for col in self.query.group_by + if ( + isinstance(self.query.group_by, tuple) + # Raise if the pk fields are not in the group_by. + and self.model._meta.pk + not in {col.output_field for col in self.query.group_by} + and set(self.model._meta.pk_fields).difference( + {col.target for col in self.query.group_by} + ) ): raise TypeError( f"Cannot use QuerySet.{method}() on an unordered queryset performing " diff --git a/docs/releases/5.2.8.txt b/docs/releases/5.2.8.txt index ef18d08022..dc750e4636 100644 --- a/docs/releases/5.2.8.txt +++ b/docs/releases/5.2.8.txt @@ -10,3 +10,7 @@ Bugfixes ======== * Added compatibility for ``oracledb`` 3.4.0 (:ticket:`36646`). + +* Fixed a bug in Django 5.2 where ``QuerySet.first()`` and ``QuerySet.last()`` + raised an error on querysets performing aggregation that selected all fields + of a composite primary key. diff --git a/tests/composite_pk/test_aggregate.py b/tests/composite_pk/test_aggregate.py index d852fdce30..8a2067cb90 100644 --- a/tests/composite_pk/test_aggregate.py +++ b/tests/composite_pk/test_aggregate.py @@ -141,3 +141,23 @@ class CompositePKAggregateTests(TestCase): msg = "Max expression does not support composite primary keys." with self.assertRaisesMessage(ValueError, msg): Comment.objects.aggregate(Max("pk")) + + def test_first_from_unordered_queryset_aggregation_pk_selected(self): + self.assertEqual( + Comment.objects.values("pk").annotate(max=Max("id")).first(), + {"pk": (1, 1), "max": 1}, + ) + + def test_first_from_unordered_queryset_aggregation_pk_selected_separately(self): + self.assertEqual( + Comment.objects.values("tenant", "id").annotate(max=Max("id")).first(), + {"tenant": 1, "id": 1, "max": 1}, + ) + + def test_first_from_unordered_queryset_aggregation_pk_incomplete(self): + msg = ( + "Cannot use QuerySet.first() on an unordered queryset performing " + "aggregation. Add an ordering with order_by()." + ) + with self.assertRaisesMessage(TypeError, msg): + Comment.objects.values("tenant").annotate(max=Max("id")).first() From 118df57d8d983d56288255acb7268a2131d97db2 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Tue, 14 Oct 2025 10:37:25 +0200 Subject: [PATCH 066/116] Moved object creation to subTest() in GISFunctionsTests.test_geometry_type() test. --- tests/gis_tests/geoapp/test_functions.py | 49 ++++++++++++------------ 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/tests/gis_tests/geoapp/test_functions.py b/tests/gis_tests/geoapp/test_functions.py index b1ab4340aa..6a0f3e1f34 100644 --- a/tests/gis_tests/geoapp/test_functions.py +++ b/tests/gis_tests/geoapp/test_functions.py @@ -916,39 +916,38 @@ class GISFunctionsTests(FuncTestMixin, TestCase): @skipUnlessDBFeature("has_GeometryType_function") def test_geometry_type(self): - Feature.objects.bulk_create( - [ - Feature(name="Point", geom=Point(0, 0)), - Feature(name="LineString", geom=LineString((0, 0), (1, 1))), - Feature(name="Polygon", geom=Polygon(((0, 0), (1, 0), (1, 1), (0, 0)))), - Feature(name="MultiPoint", geom=MultiPoint(Point(0, 0), Point(1, 1))), - Feature( - name="MultiLineString", - geom=MultiLineString( - LineString((0, 0), (1, 1)), LineString((1, 1), (2, 2)) - ), + test_features = [ + Feature(name="Point", geom=Point(0, 0)), + Feature(name="LineString", geom=LineString((0, 0), (1, 1))), + Feature(name="Polygon", geom=Polygon(((0, 0), (1, 0), (1, 1), (0, 0)))), + Feature(name="MultiPoint", geom=MultiPoint(Point(0, 0), Point(1, 1))), + Feature( + name="MultiLineString", + geom=MultiLineString( + LineString((0, 0), (1, 1)), LineString((1, 1), (2, 2)) ), - Feature( - name="MultiPolygon", - geom=MultiPolygon( - Polygon(((0, 0), (1, 0), (1, 1), (0, 0))), - Polygon(((1, 1), (2, 1), (2, 2), (1, 1))), - ), + ), + Feature( + name="MultiPolygon", + geom=MultiPolygon( + Polygon(((0, 0), (1, 0), (1, 1), (0, 0))), + Polygon(((1, 1), (2, 1), (2, 2), (1, 1))), ), - ] - ) - - expected_results = { + ), + ] + expected_results = [ ("POINT", Point), ("LINESTRING", LineString), ("POLYGON", Polygon), ("MULTIPOINT", MultiPoint), ("MULTILINESTRING", MultiLineString), ("MULTIPOLYGON", MultiPolygon), - } - - for geom_type, geom_class in expected_results: - with self.subTest(geom_type=geom_type): + ] + for test_feature, (geom_type, geom_class) in zip( + test_features, expected_results, strict=True + ): + with self.subTest(geom_type=geom_type, geom=test_feature.geom.wkt): + test_feature.save() obj = ( Feature.objects.annotate( geometry_type=functions.GeometryType("geom") From 5a2490a19d81f6f2666dd90ce79e5724b9a20a39 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Wed, 15 Oct 2025 13:57:09 +0200 Subject: [PATCH 067/116] Skipped GISFunctionsTests.test_geometry_type() test for MultiPoint on MariaDB and GEOS 3.12+. GEOSWKTWriter_write() behavior was changed in GEOS 3.12+ to include parentheses for sub-members (https://github.com/libgeos/geos/pull/903). MariaDB doesn't accept WKT representations with additional parentheses for MultiPoint. This is an accepted bug (MDEV-36166) in MariaDB that should be fixed in the future: - https://jira.mariadb.org/browse/MDEV-36166 --- tests/gis_tests/geoapp/test_functions.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/gis_tests/geoapp/test_functions.py b/tests/gis_tests/geoapp/test_functions.py index 6a0f3e1f34..917e63cd57 100644 --- a/tests/gis_tests/geoapp/test_functions.py +++ b/tests/gis_tests/geoapp/test_functions.py @@ -14,6 +14,7 @@ from django.contrib.gis.geos import ( Polygon, fromstr, ) +from django.contrib.gis.geos.libgeos import geos_version_tuple from django.contrib.gis.measure import Area from django.db import NotSupportedError, connection from django.db.models import IntegerField, Sum, Value @@ -920,7 +921,6 @@ class GISFunctionsTests(FuncTestMixin, TestCase): Feature(name="Point", geom=Point(0, 0)), Feature(name="LineString", geom=LineString((0, 0), (1, 1))), Feature(name="Polygon", geom=Polygon(((0, 0), (1, 0), (1, 1), (0, 0)))), - Feature(name="MultiPoint", geom=MultiPoint(Point(0, 0), Point(1, 1))), Feature( name="MultiLineString", geom=MultiLineString( @@ -939,10 +939,19 @@ class GISFunctionsTests(FuncTestMixin, TestCase): ("POINT", Point), ("LINESTRING", LineString), ("POLYGON", Polygon), - ("MULTIPOINT", MultiPoint), ("MULTILINESTRING", MultiLineString), ("MULTIPOLYGON", MultiPolygon), ] + # GEOSWKTWriter_write() behavior was changed in GEOS 3.12+ to include + # parentheses for sub-members. MariaDB doesn't accept WKT + # representations with additional parentheses for MultiPoint. This is + # an accepted bug (MDEV-36166) in MariaDB that should be fixed in the + # future. + if not connection.ops.mariadb or geos_version_tuple() < (3, 12): + test_features.append( + Feature(name="MultiPoint", geom=MultiPoint(Point(0, 0), Point(1, 1))) + ) + expected_results.append(("MULTIPOINT", MultiPoint)) for test_feature, (geom_type, geom_class) in zip( test_features, expected_results, strict=True ): From 2b62951fecf0024ce9ce0ce9f5be512c10747c0d Mon Sep 17 00:00:00 2001 From: Baptiste Mispelon Date: Tue, 14 Oct 2025 17:13:05 +0200 Subject: [PATCH 068/116] Fixed #36659 -- Fixed flatpage content selector in admin forms.css. Regression in bb145e2c47d71b7f68280c00ced727442d2effa2. --- django/contrib/admin/static/admin/css/forms.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/contrib/admin/static/admin/css/forms.css b/django/contrib/admin/static/admin/css/forms.css index 33f370eaba..76e2d493e9 100644 --- a/django/contrib/admin/static/admin/css/forms.css +++ b/django/contrib/admin/static/admin/css/forms.css @@ -357,7 +357,7 @@ body.popup .submit-row { width: 48em; } -.flatpages-flatpage #id_content { +.app-flatpages.model-flatpage #id_content { height: 40.2em; } From 6862d46dd96d71d80d6d2fa9873a93d811b39562 Mon Sep 17 00:00:00 2001 From: Clifford Gama Date: Thu, 9 Oct 2025 21:24:06 +0200 Subject: [PATCH 069/116] Fixed 36622 -- Prevented LazyObject FileField storages from evaluating at boot time. Co-authored-by: Fabien MICHEL --- django/db/models/fields/files.py | 2 +- tests/file_storage/models.py | 7 +++++++ tests/file_storage/tests.py | 9 +++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/django/db/models/fields/files.py b/django/db/models/fields/files.py index 5216ff565f..dbf227ae92 100644 --- a/django/db/models/fields/files.py +++ b/django/db/models/fields/files.py @@ -248,7 +248,7 @@ class FileField(Field): ): self._primary_key_set_explicitly = "primary_key" in kwargs - self.storage = storage or default_storage + self.storage = storage if storage is not None else default_storage if callable(self.storage): # Hold a reference to the callable for deconstruct(). self._storage_callable = self.storage diff --git a/tests/file_storage/models.py b/tests/file_storage/models.py index 12a54edda5..d1761c8510 100644 --- a/tests/file_storage/models.py +++ b/tests/file_storage/models.py @@ -11,6 +11,7 @@ from pathlib import Path from django.core.files.storage import FileSystemStorage, default_storage from django.db import models +from django.utils.functional import LazyObject class CustomValidNameStorage(FileSystemStorage): @@ -37,6 +38,11 @@ class CallableStorage(FileSystemStorage): return self +class LazyTempStorage(LazyObject): + def _setup(self): + self._wrapped = temp_storage + + class Storage(models.Model): def custom_upload_to(self, filename): return "foo" @@ -82,3 +88,4 @@ class Storage(models.Model): extended_length = models.FileField( storage=temp_storage, upload_to="tests", max_length=1024 ) + lazy_storage = models.FileField(storage=LazyTempStorage(), upload_to="tests") diff --git a/tests/file_storage/tests.py b/tests/file_storage/tests.py index a0234b2f9d..92962bd9a0 100644 --- a/tests/file_storage/tests.py +++ b/tests/file_storage/tests.py @@ -29,6 +29,7 @@ from django.test.utils import requires_tz_support from django.urls import NoReverseMatch, reverse_lazy from django.utils import timezone from django.utils._os import symlinks_supported +from django.utils.functional import empty from .models import ( Storage, @@ -1267,3 +1268,11 @@ class StorageHandlerTests(SimpleTestCase): ) with self.assertRaisesMessage(InvalidStorageError, msg): test_storages["invalid_backend"] + + +class StorageLazyObjectTests(SimpleTestCase): + def test_lazy_object_is_not_evaluated_before_manual_access(self): + obj = Storage() + self.assertIs(obj.lazy_storage.storage._wrapped, empty) + # assertEqual triggers resolution. + self.assertEqual(obj.lazy_storage.storage, temp_storage) From 37df013195c4ddc8aa7c4334126419a358149d3c Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Wed, 15 Oct 2025 23:15:04 +0200 Subject: [PATCH 070/116] Fixed parameter names in in "New contributor" GitHub action. Follow up to 407ab793573ce82c93cfdc86e5a0a7fa804f60f5. --- .github/workflows/new_contributor_pr.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/new_contributor_pr.yml b/.github/workflows/new_contributor_pr.yml index fd09c27f5f..958ebc64d4 100644 --- a/.github/workflows/new_contributor_pr.yml +++ b/.github/workflows/new_contributor_pr.yml @@ -15,12 +15,12 @@ jobs: # Pin to v1: https://github.com/actions/first-interaction/issues/369 - uses: actions/first-interaction@v1 with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - issue_message: | + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue-message: | Hello! Thank you for your interest in Django 💪 Django issues are tracked in [Trac](https://code.djangoproject.com/) and not in this repo. - pr_message: | + pr-message: | Hello! Thank you for your contribution 💪 As it's your first contribution be sure to check out the [patch review checklist](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/submitting-patches/#patch-review-checklist). From bee64561a6e8cd22995c2b1254bab66dae892a6d Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Wed, 15 Oct 2025 22:18:33 -0400 Subject: [PATCH 071/116] Refs #36648 -- Removed hardcoded pk in CompositePKAggregateTests. --- tests/composite_pk/test_aggregate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/composite_pk/test_aggregate.py b/tests/composite_pk/test_aggregate.py index 8a2067cb90..43f9e7a7cd 100644 --- a/tests/composite_pk/test_aggregate.py +++ b/tests/composite_pk/test_aggregate.py @@ -145,13 +145,13 @@ class CompositePKAggregateTests(TestCase): def test_first_from_unordered_queryset_aggregation_pk_selected(self): self.assertEqual( Comment.objects.values("pk").annotate(max=Max("id")).first(), - {"pk": (1, 1), "max": 1}, + {"pk": (self.comment_1.tenant_id, 1), "max": 1}, ) def test_first_from_unordered_queryset_aggregation_pk_selected_separately(self): self.assertEqual( Comment.objects.values("tenant", "id").annotate(max=Max("id")).first(), - {"tenant": 1, "id": 1, "max": 1}, + {"tenant": self.comment_1.tenant_id, "id": 1, "max": 1}, ) def test_first_from_unordered_queryset_aggregation_pk_incomplete(self): From f6bd90c84050a1c74fe2161cced00e7282cb845c Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Thu, 20 Feb 2025 21:57:36 +0000 Subject: [PATCH 072/116] Refs #28586 -- Edited related objects documentation. This change aims to make this section clearer and ready to add a description of fetch modes. --- docs/topics/db/queries.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/topics/db/queries.txt b/docs/topics/db/queries.txt index 3451f71fba..d24505b039 100644 --- a/docs/topics/db/queries.txt +++ b/docs/topics/db/queries.txt @@ -1683,15 +1683,15 @@ a join with an ``F()`` object, a ``FieldError`` will be raised: Related objects =============== -When you define a relationship in a model (i.e., a +When you define a relationship in a model (with :class:`~django.db.models.ForeignKey`, :class:`~django.db.models.OneToOneField`, or -:class:`~django.db.models.ManyToManyField`), instances of that model will have -a convenient API to access the related object(s). +:class:`~django.db.models.ManyToManyField`), instances of the model class gain +accessor attributes for the related object(s). Using the models at the top of this page, for example, an ``Entry`` object -``e`` can get its associated ``Blog`` object by accessing the ``blog`` -attribute: ``e.blog``. +``e`` has its associated ``Blog`` object accessible in its ``blog`` attribute: +``e.blog``. (Behind the scenes, this functionality is implemented by Python :doc:`descriptors `. This shouldn't really matter to @@ -1699,8 +1699,8 @@ you, but we point it out here for the curious.) Django also creates API accessors for the "other" side of the relationship -- the link from the related model to the model that defines the relationship. -For example, a ``Blog`` object ``b`` has access to a list of all related -``Entry`` objects via the ``entry_set`` attribute: ``b.entry_set.all()``. +For example, a ``Blog`` object ``b`` has a manager that returns all related +``Entry`` objects in the ``entry_set`` attribute: ``b.entry_set.all()``. All examples in this section use the sample ``Blog``, ``Author`` and ``Entry`` models defined at the top of this page. From e097e8a12f21a4e92594830f1ad1942b31916d0f Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Wed, 29 Nov 2023 09:35:34 +0000 Subject: [PATCH 073/116] Fixed #28586 -- Added model field fetch modes. May your database queries be much reduced with minimal effort. co-authored-by: Andreas Pelme co-authored-by: Simon Charette co-authored-by: Jacob Walls --- django/contrib/contenttypes/fields.py | 16 +- django/core/exceptions.py | 6 + django/db/models/__init__.py | 4 + django/db/models/base.py | 25 +++- django/db/models/fetch_modes.py | 52 +++++++ .../db/models/fields/related_descriptors.py | 67 ++++++--- django/db/models/query.py | 33 ++++- django/db/models/query_utils.py | 17 ++- docs/ref/exceptions.txt | 10 ++ docs/ref/models/instances.txt | 8 +- docs/ref/models/querysets.txt | 60 +++++--- docs/releases/6.1.txt | 45 ++++++ docs/spelling_wordlist | 1 + docs/topics/db/fetch-modes.txt | 138 ++++++++++++++++++ docs/topics/db/index.txt | 1 + docs/topics/db/optimization.txt | 48 ++++-- docs/topics/db/queries.txt | 6 + tests/basic/tests.py | 1 + tests/defer/tests.py | 68 ++++++++- tests/generic_relations/tests.py | 43 +++++- tests/many_to_one/tests.py | 27 +++- tests/one_to_one/tests.py | 38 +++++ tests/prefetch_related/tests.py | 5 + tests/raw_query/tests.py | 36 ++++- 24 files changed, 682 insertions(+), 73 deletions(-) create mode 100644 django/db/models/fetch_modes.py create mode 100644 docs/topics/db/fetch-modes.txt diff --git a/django/contrib/contenttypes/fields.py b/django/contrib/contenttypes/fields.py index f98dda1255..aa41eab370 100644 --- a/django/contrib/contenttypes/fields.py +++ b/django/contrib/contenttypes/fields.py @@ -16,6 +16,7 @@ from django.db.models.fields.related import ( ReverseManyToOneDescriptor, lazy_related_operation, ) +from django.db.models.query import prefetch_related_objects from django.db.models.query_utils import PathInfo from django.db.models.sql import AND from django.db.models.sql.where import WhereNode @@ -253,6 +254,15 @@ class GenericForeignKeyDescriptor: return rel_obj else: rel_obj = None + + instance._state.fetch_mode.fetch(self, instance) + return self.field.get_cached_value(instance) + + def fetch_one(self, instance): + f = self.field.model._meta.get_field(self.field.ct_field) + ct_id = getattr(instance, f.attname, None) + pk_val = getattr(instance, self.field.fk_field) + rel_obj = None if ct_id is not None: ct = self.field.get_content_type(id=ct_id, using=instance._state.db) try: @@ -262,7 +272,11 @@ class GenericForeignKeyDescriptor: except ObjectDoesNotExist: pass self.field.set_cached_value(instance, rel_obj) - return rel_obj + + def fetch_many(self, instances): + is_cached = self.field.is_cached + missing_instances = [i for i in instances if not is_cached(i)] + return prefetch_related_objects(missing_instances, self.field.name) def __set__(self, instance, value): ct = None diff --git a/django/core/exceptions.py b/django/core/exceptions.py index cbc80bd78f..0e24f6cb18 100644 --- a/django/core/exceptions.py +++ b/django/core/exceptions.py @@ -132,6 +132,12 @@ class FieldError(Exception): pass +class FieldFetchBlocked(FieldError): + """On-demand fetching of a model field blocked.""" + + pass + + NON_FIELD_ERRORS = "__all__" diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py index ec54b65240..f15ddecfaa 100644 --- a/django/db/models/__init__.py +++ b/django/db/models/__init__.py @@ -36,6 +36,7 @@ from django.db.models.expressions import ( WindowFrame, WindowFrameExclusion, ) +from django.db.models.fetch_modes import FETCH_ONE, FETCH_PEERS, RAISE from django.db.models.fields import * # NOQA from django.db.models.fields import __all__ as fields_all from django.db.models.fields.composite import CompositePrimaryKey @@ -105,6 +106,9 @@ __all__ += [ "GeneratedField", "JSONField", "OrderWrt", + "FETCH_ONE", + "FETCH_PEERS", + "RAISE", "Lookup", "Transform", "Manager", diff --git a/django/db/models/base.py b/django/db/models/base.py index fd51052d01..b92a198660 100644 --- a/django/db/models/base.py +++ b/django/db/models/base.py @@ -32,6 +32,7 @@ from django.db.models import NOT_PROVIDED, ExpressionWrapper, IntegerField, Max, from django.db.models.constants import LOOKUP_SEP from django.db.models.deletion import CASCADE, Collector from django.db.models.expressions import DatabaseDefault +from django.db.models.fetch_modes import FETCH_ONE from django.db.models.fields.composite import CompositePrimaryKey from django.db.models.fields.related import ( ForeignObjectRel, @@ -466,6 +467,14 @@ class ModelStateFieldsCacheDescriptor: return res +class ModelStateFetchModeDescriptor: + def __get__(self, instance, cls=None): + if instance is None: + return self + res = instance.fetch_mode = FETCH_ONE + return res + + class ModelState: """Store model instance state.""" @@ -476,6 +485,14 @@ class ModelState: # on the actual save. adding = True fields_cache = ModelStateFieldsCacheDescriptor() + fetch_mode = ModelStateFetchModeDescriptor() + peers = () + + def __getstate__(self): + state = self.__dict__.copy() + # Weak references can't be pickled. + state.pop("peers", None) + return state class Model(AltersData, metaclass=ModelBase): @@ -595,7 +612,7 @@ class Model(AltersData, metaclass=ModelBase): post_init.send(sender=cls, instance=self) @classmethod - def from_db(cls, db, field_names, values): + def from_db(cls, db, field_names, values, *, fetch_mode=None): if len(values) != len(cls._meta.concrete_fields): values_iter = iter(values) values = [ @@ -605,6 +622,8 @@ class Model(AltersData, metaclass=ModelBase): new = cls(*values) new._state.adding = False new._state.db = db + if fetch_mode is not None: + new._state.fetch_mode = fetch_mode return new def __repr__(self): @@ -714,8 +733,8 @@ class Model(AltersData, metaclass=ModelBase): should be an iterable of field attnames. If fields is None, then all non-deferred fields are reloaded. - When accessing deferred fields of an instance, the deferred loading - of the field will call this method. + When fetching deferred fields for a single instance (the FETCH_ONE + fetch mode), the deferred loading uses this method. """ if fields is None: self._prefetched_objects_cache = {} diff --git a/django/db/models/fetch_modes.py b/django/db/models/fetch_modes.py new file mode 100644 index 0000000000..a22ccd8a23 --- /dev/null +++ b/django/db/models/fetch_modes.py @@ -0,0 +1,52 @@ +from django.core.exceptions import FieldFetchBlocked + + +class FetchMode: + __slots__ = () + + track_peers = False + + def fetch(self, fetcher, instance): + raise NotImplementedError("Subclasses must implement this method.") + + +class FetchOne(FetchMode): + __slots__ = () + + def fetch(self, fetcher, instance): + fetcher.fetch_one(instance) + + +FETCH_ONE = FetchOne() + + +class FetchPeers(FetchMode): + __slots__ = () + + track_peers = True + + def fetch(self, fetcher, instance): + instances = [ + peer + for peer_weakref in instance._state.peers + if (peer := peer_weakref()) is not None + ] + if len(instances) > 1: + fetcher.fetch_many(instances) + else: + fetcher.fetch_one(instance) + + +FETCH_PEERS = FetchPeers() + + +class Raise(FetchMode): + __slots__ = () + + def fetch(self, fetcher, instance): + klass = instance.__class__.__qualname__ + field_name = fetcher.field.name + raise FieldFetchBlocked(f"Fetching of {klass}.{field_name} blocked.") from None + + +RAISE = Raise() diff --git a/django/db/models/fields/related_descriptors.py b/django/db/models/fields/related_descriptors.py index 3e2150e0f6..2c8e59f1d9 100644 --- a/django/db/models/fields/related_descriptors.py +++ b/django/db/models/fields/related_descriptors.py @@ -78,7 +78,7 @@ from django.db.models.expressions import ColPairs from django.db.models.fields.tuple_lookups import TupleIn from django.db.models.functions import RowNumber from django.db.models.lookups import GreaterThan, LessThanOrEqual -from django.db.models.query import QuerySet +from django.db.models.query import QuerySet, prefetch_related_objects from django.db.models.query_utils import DeferredAttribute from django.db.models.utils import AltersData, resolve_callables from django.utils.functional import cached_property @@ -254,13 +254,9 @@ class ForwardManyToOneDescriptor: break if rel_obj is None and has_value: - rel_obj = self.get_object(instance) - remote_field = self.field.remote_field - # If this is a one-to-one relation, set the reverse accessor - # cache on the related object to the current instance to avoid - # an extra SQL query if it's accessed later on. - if not remote_field.multiple: - remote_field.set_cached_value(rel_obj, instance) + instance._state.fetch_mode.fetch(self, instance) + return self.field.get_cached_value(instance) + self.field.set_cached_value(instance, rel_obj) if rel_obj is None and not self.field.null: @@ -270,6 +266,21 @@ class ForwardManyToOneDescriptor: else: return rel_obj + def fetch_one(self, instance): + rel_obj = self.get_object(instance) + self.field.set_cached_value(instance, rel_obj) + # If this is a one-to-one relation, set the reverse accessor cache on + # the related object to the current instance to avoid an extra SQL + # query if it's accessed later on. + remote_field = self.field.remote_field + if not remote_field.multiple: + remote_field.set_cached_value(rel_obj, instance) + + def fetch_many(self, instances): + is_cached = self.is_cached + missing_instances = [i for i in instances if not is_cached(i)] + prefetch_related_objects(missing_instances, self.field.name) + def __set__(self, instance, value): """ Set the related instance through the forward relation. @@ -504,16 +515,8 @@ class ReverseOneToOneDescriptor: if not instance._is_pk_set(): rel_obj = None else: - filter_args = self.related.field.get_forward_related_filter(instance) - try: - rel_obj = self.get_queryset(instance=instance).get(**filter_args) - except self.related.related_model.DoesNotExist: - rel_obj = None - else: - # Set the forward accessor cache on the related object to - # the current instance to avoid an extra SQL query if it's - # accessed later on. - self.related.field.set_cached_value(rel_obj, instance) + instance._state.fetch_mode.fetch(self, instance) + rel_obj = self.related.get_cached_value(instance) self.related.set_cached_value(instance, rel_obj) if rel_obj is None: @@ -524,6 +527,34 @@ class ReverseOneToOneDescriptor: else: return rel_obj + @property + def field(self): + """ + Add compatibility with the fetcher protocol. While self.related is not + a field but a OneToOneRel, it quacks enough like a field to work. + """ + return self.related + + def fetch_one(self, instance): + # Kept for backwards compatibility with overridden + # get_forward_related_filter() + filter_args = self.related.field.get_forward_related_filter(instance) + try: + rel_obj = self.get_queryset(instance=instance).get(**filter_args) + except self.related.related_model.DoesNotExist: + rel_obj = None + else: + self.related.field.set_cached_value(rel_obj, instance) + self.related.set_cached_value(instance, rel_obj) + + def fetch_many(self, instances): + is_cached = self.is_cached + missing_instances = [i for i in instances if not is_cached(i)] + prefetch_related_objects( + missing_instances, + self.related.get_accessor_name(), + ) + def __set__(self, instance, value): """ Set the related instance through the reverse relation. diff --git a/django/db/models/query.py b/django/db/models/query.py index 39cc9b6cb3..0811b90b5e 100644 --- a/django/db/models/query.py +++ b/django/db/models/query.py @@ -8,6 +8,7 @@ import warnings from contextlib import nullcontext from functools import reduce from itertools import chain, islice +from weakref import ref as weak_ref from asgiref.sync import sync_to_async @@ -26,6 +27,7 @@ from django.db.models import AutoField, DateField, DateTimeField, Field, Max, sq from django.db.models.constants import LOOKUP_SEP, OnConflict from django.db.models.deletion import Collector from django.db.models.expressions import Case, DatabaseDefault, F, Value, When +from django.db.models.fetch_modes import FETCH_ONE from django.db.models.functions import Cast, Trunc from django.db.models.query_utils import FilteredRelation, Q from django.db.models.sql.constants import GET_ITERATOR_CHUNK_SIZE, ROW_COUNT @@ -122,10 +124,18 @@ class ModelIterable(BaseIterable): ) for field, related_objs in queryset._known_related_objects.items() ] + fetch_mode = queryset._fetch_mode + peers = [] for row in compiler.results_iter(results): obj = model_cls.from_db( - db, init_list, row[model_fields_start:model_fields_end] + db, + init_list, + row[model_fields_start:model_fields_end], + fetch_mode=fetch_mode, ) + if fetch_mode.track_peers: + peers.append(weak_ref(obj)) + obj._state.peers = peers for rel_populator in related_populators: rel_populator.populate(row, obj) if annotation_col_map: @@ -183,10 +193,17 @@ class RawModelIterable(BaseIterable): query_iterator = compiler.composite_fields_to_tuples( query_iterator, cols ) + fetch_mode = self.queryset._fetch_mode + peers = [] for values in query_iterator: # Associate fields to values model_init_values = [values[pos] for pos in model_init_pos] - instance = model_cls.from_db(db, model_init_names, model_init_values) + instance = model_cls.from_db( + db, model_init_names, model_init_values, fetch_mode=fetch_mode + ) + if fetch_mode.track_peers: + peers.append(weak_ref(instance)) + instance._state.peers = peers if annotation_fields: for column, pos in annotation_fields: setattr(instance, column, values[pos]) @@ -293,6 +310,7 @@ class QuerySet(AltersData): self._prefetch_done = False self._known_related_objects = {} # {rel_field: {pk: rel_obj}} self._iterable_class = ModelIterable + self._fetch_mode = FETCH_ONE self._fields = None self._defer_next_filter = False self._deferred_filter = None @@ -1442,6 +1460,7 @@ class QuerySet(AltersData): params=params, translations=translations, using=using, + fetch_mode=self._fetch_mode, ) qs._prefetch_related_lookups = self._prefetch_related_lookups[:] return qs @@ -1913,6 +1932,12 @@ class QuerySet(AltersData): clone._db = alias return clone + def fetch_mode(self, fetch_mode): + """Set the fetch mode for the QuerySet.""" + clone = self._chain() + clone._fetch_mode = fetch_mode + return clone + ################################### # PUBLIC INTROSPECTION ATTRIBUTES # ################################### @@ -2051,6 +2076,7 @@ class QuerySet(AltersData): c._prefetch_related_lookups = self._prefetch_related_lookups[:] c._known_related_objects = self._known_related_objects c._iterable_class = self._iterable_class + c._fetch_mode = self._fetch_mode c._fields = self._fields return c @@ -2186,6 +2212,7 @@ class RawQuerySet: translations=None, using=None, hints=None, + fetch_mode=FETCH_ONE, ): self.raw_query = raw_query self.model = model @@ -2197,6 +2224,7 @@ class RawQuerySet: self._result_cache = None self._prefetch_related_lookups = () self._prefetch_done = False + self._fetch_mode = fetch_mode def resolve_model_init_order(self): """Resolve the init field names and value positions.""" @@ -2295,6 +2323,7 @@ class RawQuerySet: params=self.params, translations=self.translations, using=alias, + fetch_mode=self._fetch_mode, ) @cached_property diff --git a/django/db/models/query_utils.py b/django/db/models/query_utils.py index c383b80640..23d543211a 100644 --- a/django/db/models/query_utils.py +++ b/django/db/models/query_utils.py @@ -264,7 +264,8 @@ class DeferredAttribute: f"Cannot retrieve deferred field {field_name!r} " "from an unsaved model." ) - instance.refresh_from_db(fields=[field_name]) + + instance._state.fetch_mode.fetch(self, instance) else: data[field_name] = val return data[field_name] @@ -281,6 +282,20 @@ class DeferredAttribute: return getattr(instance, link_field.attname) return None + def fetch_one(self, instance): + instance.refresh_from_db(fields=[self.field.attname]) + + def fetch_many(self, instances): + attname = self.field.attname + db = instances[0]._state.db + value_by_pk = ( + self.field.model._base_manager.using(db) + .values_list(attname) + .in_bulk({i.pk for i in instances}) + ) + for instance in instances: + setattr(instance, attname, value_by_pk[instance.pk]) + class class_or_instance_method: """ diff --git a/docs/ref/exceptions.txt b/docs/ref/exceptions.txt index bbd959e95d..93c6ec4203 100644 --- a/docs/ref/exceptions.txt +++ b/docs/ref/exceptions.txt @@ -165,6 +165,16 @@ Django core exception classes are defined in ``django.core.exceptions``. - A field name is invalid - A query contains invalid order_by arguments +``FieldFetchBlocked`` +--------------------- + +.. versionadded:: 6.1 + +.. exception:: FieldFetchBlocked + + Raised when a field would be fetched on-demand and the + :attr:`~django.db.models.RAISE` fetch mode is active. + ``ValidationError`` ------------------- diff --git a/docs/ref/models/instances.txt b/docs/ref/models/instances.txt index c8cf5957ba..2ce8dc4a36 100644 --- a/docs/ref/models/instances.txt +++ b/docs/ref/models/instances.txt @@ -180,10 +180,10 @@ update, you could write a test similar to this:: obj.refresh_from_db() self.assertEqual(obj.val, 2) -Note that when deferred fields are accessed, the loading of the deferred -field's value happens through this method. Thus it is possible to customize -the way deferred loading happens. The example below shows how one can reload -all of the instance's fields when a deferred field is reloaded:: +When a deferred field is loaded on-demand for a single model instance, the +loading happens through this method. Thus it is possible to customize the way +this loading happens. The example below shows how one can reload all of the +instance's fields when a deferred field is loaded on-demand:: class ExampleModel(models.Model): def refresh_from_db(self, using=None, fields=None, **kwargs): diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index f290970d2c..3840a2f97e 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -1022,15 +1022,38 @@ Uses SQL's ``EXCEPT`` operator to keep only elements present in the See :meth:`union` for some restrictions. +``fetch_mode()`` +~~~~~~~~~~~~~~~~ + +.. versionadded:: 6.1 + +.. method:: fetch_mode(mode) + +Returns a ``QuerySet`` that sets the given fetch mode for all model instances +created by this ``QuerySet``. The fetch mode controls on-demand loading of +fields when they are accessed, such as for foreign keys and deferred fields. +For example, to use the :attr:`~django.db.models.FETCH_PEERS` mode to +batch-load all related objects on first access: + +.. code-block:: python + + from django.db import models + + books = Book.objects.fetch_mode(models.FETCH_PEERS) + +See more in the :doc:`fetch mode topic guide `. + ``select_related()`` ~~~~~~~~~~~~~~~~~~~~ .. method:: select_related(*fields) -Returns a ``QuerySet`` that will "follow" foreign-key relationships, selecting -additional related-object data when it executes its query. This is a -performance booster which results in a single more complex query but means -later use of foreign-key relationships won't require database queries. +Returns a ``QuerySet`` that will join in the named foreign-key relationships, +selecting additional related objects when it executes its query. This method +can be a performance booster, fetching data ahead of time rather than +triggering on-demand loading through the model instances' +:doc:`fetch mode `, at the cost of a more complex +initial query. The following examples illustrate the difference between plain lookups and ``select_related()`` lookups. Here's standard lookup:: @@ -1050,20 +1073,8 @@ And here's ``select_related`` lookup:: # in the previous query. b = e.blog -You can use ``select_related()`` with any queryset of objects:: - - from django.utils import timezone - - # Find all the blogs with entries scheduled to be published in the future. - blogs = set() - - for e in Entry.objects.filter(pub_date__gt=timezone.now()).select_related("blog"): - # Without select_related(), this would make a database query for each - # loop iteration in order to fetch the related blog for each entry. - blogs.add(e.blog) - -The order of ``filter()`` and ``select_related()`` chaining isn't important. -These querysets are equivalent:: +You can use ``select_related()`` with any queryset. The order of chaining with +other methods isn't important. For example, these querysets are equivalent:: Entry.objects.filter(pub_date__gt=timezone.now()).select_related("blog") Entry.objects.select_related("blog").filter(pub_date__gt=timezone.now()) @@ -1141,12 +1152,15 @@ that is that ``select_related('foo', 'bar')`` is equivalent to .. method:: prefetch_related(*lookups) -Returns a ``QuerySet`` that will automatically retrieve, in a single batch, -related objects for each of the specified lookups. +Returns a ``QuerySet`` that will automatically retrieve the given lookups, each +in one extra batch query. Prefetching is a way to optimize database access +when you know you'll be accessing related objects later, so you can avoid +triggering the on-demand loading behavior of the model instances' +:doc:`fetch mode `. -This has a similar purpose to ``select_related``, in that both are designed to -stop the deluge of database queries that is caused by accessing related -objects, but the strategy is quite different. +This method has a similar purpose to :meth:`select_related`, in that both are +designed to eagerly fetch related objects. However, they work in different +ways. ``select_related`` works by creating an SQL join and including the fields of the related object in the ``SELECT`` statement. For this reason, diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 5e852785d9..80470dbcd6 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -26,6 +26,51 @@ only officially support, the latest release of each series. What's new in Django 6.1 ======================== +Model field fetch modes +----------------------- + +The on-demand fetching behavior of model fields is now configurable with +:doc:`fetch modes `. These modes allow you to control +how Django fetches data from the database when an unfetched field is accessed. + +Django provides three fetch modes: + +1. ``FETCH_ONE``, the default, fetches the missing field for the current + instance only. This mode represents Django's existing behavior. + +2. ``FETCH_PEERS`` fetches a missing field for all instances that came from + the same :class:`~django.db.models.query.QuerySet`. + + This mode works like an on-demand ``prefetch_related()``. It can reduce most + cases of the "N+1 queries problem" to two queries without any work to + maintain a list of fields to prefetch. + +3. ``RAISE`` raises a :exc:`~django.core.exceptions.FieldFetchBlocked` + exception. + + This mode can prevent unintentional queries in performance-critical + sections of code. + +Use the new method :meth:`.QuerySet.fetch_mode` to set the fetch mode for model +instances fetched by the ``QuerySet``: + +.. code-block:: python + + from django.db import models + + books = Book.objects.fetch_mode(models.FETCH_PEERS) + for book in books: + print(book.author.name) + +Despite the loop accessing the ``author`` foreign key on each instance, the +``FETCH_PEERS`` fetch mode will make the above example perform only two +queries: + +1. Fetch all books. +2. Fetch associated authors. + +See :doc:`fetch modes ` for more details. + Minor features -------------- diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 2898f85d5b..b35c94fc10 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -535,6 +535,7 @@ unencrypted unescape unescaped unevaluated +unfetched unglamorous ungrouped unhandled diff --git a/docs/topics/db/fetch-modes.txt b/docs/topics/db/fetch-modes.txt new file mode 100644 index 0000000000..e76bb28a59 --- /dev/null +++ b/docs/topics/db/fetch-modes.txt @@ -0,0 +1,138 @@ +=========== +Fetch modes +=========== + +.. versionadded:: 6.1 + +.. module:: django.db.models.fetch_modes + +.. currentmodule:: django.db.models + +When accessing model fields that were not loaded as part of the original query, +Django will fetch that field's data from the database. You can customize the +behavior of this fetching with a **fetch mode**, making it more efficient or +even blocking it. + +Use :meth:`.QuerySet.fetch_mode` to set the fetch mode for model +instances fetched by a ``QuerySet``: + +.. code-block:: python + + from django.db import models + + books = Book.objects.fetch_mode(models.FETCH_PEERS) + +Fetch modes apply to: + +* :class:`~django.db.models.ForeignKey` fields +* :class:`~django.db.models.OneToOneField` fields and their reverse accessors +* Fields deferred with :meth:`.QuerySet.defer` or :meth:`.QuerySet.only` +* :ref:`generic-relations` + +Available modes +=============== + +.. admonition:: Referencing fetch modes + + Fetch modes are defined in ``django.db.models.fetch_modes``, but for + convenience they're imported into :mod:`django.db.models`. The standard + convention is to use ``from django.db import models`` and refer to the + fetch modes as ``models.``. + +Django provides three fetch modes. We'll explain them below using these models: + +.. code-block:: python + + from django.db import models + + + class Author(models.Model): ... + + + class Book(models.Model): + author = models.ForeignKey(Author, on_delete=models.CASCADE) + ... + +…and this loop: + +.. code-block:: python + + for book in books: + print(book.author.name) + +…where ``books`` is a ``QuerySet`` of ``Book`` instances using some fetch mode. + +.. attribute:: FETCH_ONE + +Fetches the missing field for the current instance only. This is the default +mode. + +Using ``FETCH_ONE`` for the above example would use: + +* 1 query to fetch ``books`` +* N queries, where N is the number of books, to fetch the missing ``author`` + field + +…for a total of 1+N queries. This query pattern is known as the "N+1 queries +problem" because it often leads to performance issues when N is large. + +.. attribute:: FETCH_PEERS + +Fetches the missing field for the current instance and its "peers"—instances +that came from the same initial ``QuerySet``. The behavior of this mode is +based on the assumption that if you need a field for one instance, you probably +need it for all instances in the same batch, since you'll likely process them +all identically. + +Using ``FETCH_PEERS`` for the above example would use: + +* 1 query to fetch ``books`` +* 1 query to fetch all missing ``author`` fields for the batch of books + +…for a total of 2 queries. The batch query makes this mode a lot more efficient +than ``FETCH_ONE`` and is similar to an on-demand call to +:meth:`.QuerySet.prefetch_related` or +:func:`~django.db.models.prefetch_related_objects`. Using ``FETCH_PEERS`` can +reduce most cases of the "N+1 queries problem" to two queries without +much effort. + +The "peer" instances are tracked in a list of weak references, to avoid +memory leaks where some peer instances are discarded. + +.. attribute:: RAISE + +Raises a :exc:`~django.core.exceptions.FieldFetchBlocked` exception. + +Using ``RAISE`` for the above example would raise an exception at the access of +``book.author`` access, like: + +.. code-block:: python + + FieldFetchBlocked("Fetching of Primary.value blocked.") + +This mode can prevent unintentional queries in performance-critical +sections of code. + +.. _fetch-modes-custom-manager: + +Make a fetch mode the default for a model class +=============================================== + +Set the default fetch mode for a model class with a +:ref:`custom manager ` that overrides ``get_queryset()``: + +.. code-block:: python + + from django.db import models + + + class BookManager(models.Manager): + def get_queryset(self): + return super().get_queryset().fetch_mode(models.FETCH_PEERS) + + + class Book(models.Model): + title = models.TextField() + author = models.ForeignKey("Author", on_delete=models.CASCADE) + + objects = BookManager() diff --git a/docs/topics/db/index.txt b/docs/topics/db/index.txt index 67a71fd820..6caf9f15e9 100644 --- a/docs/topics/db/index.txt +++ b/docs/topics/db/index.txt @@ -13,6 +13,7 @@ Generally, each model maps to a single database table. models queries + fetch-modes aggregation search managers diff --git a/docs/topics/db/optimization.txt b/docs/topics/db/optimization.txt index bb70efa362..3be0bd2cb5 100644 --- a/docs/topics/db/optimization.txt +++ b/docs/topics/db/optimization.txt @@ -196,28 +196,46 @@ thousands of records are returned. The penalty will be compounded if the database lives on a separate server, where network overhead and latency also play a factor. -Retrieve everything at once if you know you will need it -======================================================== +Retrieve related objects efficiently +==================================== -Hitting the database multiple times for different parts of a single 'set' of -data that you will need all parts of is, in general, less efficient than -retrieving it all in one query. This is particularly important if you have a -query that is executed in a loop, and could therefore end up doing many -database queries, when only one was needed. So: +Generally, accessing the database multiple times to retrieve different parts +of a single "set" of data is less efficient than retrieving it all in one +query. This is particularly important if you have a query that is executed in a +loop, and could therefore end up doing many database queries, when only one +is needed. Below are some techniques to combine queries for efficiency. + +Use the ``FETCH_PEERS`` fetch mode +---------------------------------- + +Use the :attr:`~django.db.models.FETCH_PEERS` fetch mode to make on-demand +field access more efficient with bulk-fetching. Enable all it for all usage of +your models :ref:`with a custom manager `. + +Using this fetch mode is easier than declaring fields to fetch with +:meth:`~django.db.models.query.QuerySet.select_related` or +:meth:`~django.db.models.query.QuerySet.prefetch_related`, especially when it's +hard to predict which fields will be accessed. Use ``QuerySet.select_related()`` and ``prefetch_related()`` ------------------------------------------------------------ -Understand :meth:`~django.db.models.query.QuerySet.select_related` and -:meth:`~django.db.models.query.QuerySet.prefetch_related` thoroughly, and use -them: +When the :attr:`~django.db.models.FETCH_PEERS` fetch mode is not appropriate or +efficient enough, use :meth:`~django.db.models.query.QuerySet.select_related` +and :meth:`~django.db.models.query.QuerySet.prefetch_related`. Understand their +documentation thoroughly and apply them where needed. -* in :doc:`managers and default managers ` where - appropriate. Be aware when your manager is and is not used; sometimes this is - tricky so don't make assumptions. +It may be useful to apply these methods in :doc:`managers and default managers +`. Be aware when your manager is and is not used; +sometimes this is tricky so don't make assumptions. -* in view code or other layers, possibly making use of - :func:`~django.db.models.prefetch_related_objects` where needed. +Use ``prefetch_related_objects()`` +---------------------------------- + +Where :attr:`~django.db.models.query.QuerySet.prefetch_related` would be useful +after the queryset has been evaluated, use +:func:`~django.db.models.prefetch_related_objects` to execute an extra +prefetch. Don't retrieve things you don't need ==================================== diff --git a/docs/topics/db/queries.txt b/docs/topics/db/queries.txt index d24505b039..ed1d3ea9ed 100644 --- a/docs/topics/db/queries.txt +++ b/docs/topics/db/queries.txt @@ -1702,6 +1702,12 @@ the link from the related model to the model that defines the relationship. For example, a ``Blog`` object ``b`` has a manager that returns all related ``Entry`` objects in the ``entry_set`` attribute: ``b.entry_set.all()``. +These accessors may be prefetched by the ``QuerySet`` methods +:meth:`~django.db.models.query.QuerySet.select_related` or +:meth:`~django.db.models.query.QuerySet.prefetch_related`. If not prefetched, +access will trigger an on-demand fetch through the model's +:doc:`fetch mode `. + All examples in this section use the sample ``Blog``, ``Author`` and ``Entry`` models defined at the top of this page. diff --git a/tests/basic/tests.py b/tests/basic/tests.py index 38d7d2a3d6..89aef16aef 100644 --- a/tests/basic/tests.py +++ b/tests/basic/tests.py @@ -807,6 +807,7 @@ class ManagerTest(SimpleTestCase): "alatest", "aupdate", "aupdate_or_create", + "fetch_mode", ] def test_manager_methods(self): diff --git a/tests/defer/tests.py b/tests/defer/tests.py index c0968080b1..29c63c566a 100644 --- a/tests/defer/tests.py +++ b/tests/defer/tests.py @@ -1,4 +1,5 @@ -from django.core.exceptions import FieldDoesNotExist, FieldError +from django.core.exceptions import FieldDoesNotExist, FieldError, FieldFetchBlocked +from django.db.models import FETCH_PEERS, RAISE from django.test import SimpleTestCase, TestCase from .models import ( @@ -29,6 +30,7 @@ class DeferTests(AssertionMixin, TestCase): def setUpTestData(cls): cls.s1 = Secondary.objects.create(first="x1", second="y1") cls.p1 = Primary.objects.create(name="p1", value="xx", related=cls.s1) + cls.p2 = Primary.objects.create(name="p2", value="yy", related=cls.s1) def test_defer(self): qs = Primary.objects.all() @@ -141,7 +143,6 @@ class DeferTests(AssertionMixin, TestCase): def test_saving_object_with_deferred_field(self): # Saving models with deferred fields is possible (but inefficient, # since every field has to be retrieved first). - Primary.objects.create(name="p2", value="xy", related=self.s1) obj = Primary.objects.defer("value").get(name="p2") obj.name = "a new name" obj.save() @@ -181,10 +182,71 @@ class DeferTests(AssertionMixin, TestCase): self.assertEqual(obj.name, "adonis") def test_defer_fk_attname(self): - primary = Primary.objects.defer("related_id").get() + primary = Primary.objects.defer("related_id").get(name="p1") with self.assertNumQueries(1): self.assertEqual(primary.related_id, self.p1.related_id) + def test_only_fetch_mode_fetch_peers(self): + p1, p2 = Primary.objects.fetch_mode(FETCH_PEERS).only("name") + with self.assertNumQueries(1): + p1.value + with self.assertNumQueries(0): + p2.value + + def test_only_fetch_mode_fetch_peers_single(self): + p1 = Primary.objects.fetch_mode(FETCH_PEERS).only("name").get(name="p1") + with self.assertNumQueries(1): + p1.value + + def test_defer_fetch_mode_fetch_peers(self): + p1, p2 = Primary.objects.fetch_mode(FETCH_PEERS).defer("value") + with self.assertNumQueries(1): + p1.value + with self.assertNumQueries(0): + p2.value + + def test_defer_fetch_mode_fetch_peers_single(self): + p1 = Primary.objects.fetch_mode(FETCH_PEERS).defer("value").get(name="p1") + with self.assertNumQueries(1): + p1.value + + def test_only_fetch_mode_raise(self): + p1 = Primary.objects.fetch_mode(RAISE).only("name").get(name="p1") + msg = "Fetching of Primary.value blocked." + with self.assertRaisesMessage(FieldFetchBlocked, msg) as cm: + p1.value + self.assertIsNone(cm.exception.__cause__) + self.assertTrue(cm.exception.__suppress_context__) + + def test_defer_fetch_mode_raise(self): + p1 = Primary.objects.fetch_mode(RAISE).defer("value").get(name="p1") + msg = "Fetching of Primary.value blocked." + with self.assertRaisesMessage(FieldFetchBlocked, msg) as cm: + p1.value + self.assertIsNone(cm.exception.__cause__) + self.assertTrue(cm.exception.__suppress_context__) + + +class DeferOtherDatabaseTests(TestCase): + databases = {"other"} + + @classmethod + def setUpTestData(cls): + cls.s1 = Secondary.objects.using("other").create(first="x1", second="y1") + cls.p1 = Primary.objects.using("other").create( + name="p1", value="xx", related=cls.s1 + ) + cls.p2 = Primary.objects.using("other").create( + name="p2", value="yy", related=cls.s1 + ) + + def test_defer_fetch_mode_fetch_peers(self): + p1, p2 = Primary.objects.using("other").fetch_mode(FETCH_PEERS).defer("value") + with self.assertNumQueries(1, using="other"): + p1.value + with self.assertNumQueries(0, using="other"): + p2.value + class BigChildDeferTests(AssertionMixin, TestCase): @classmethod diff --git a/tests/generic_relations/tests.py b/tests/generic_relations/tests.py index 1b53dbd8f4..3de243d7b8 100644 --- a/tests/generic_relations/tests.py +++ b/tests/generic_relations/tests.py @@ -1,7 +1,8 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.prefetch import GenericPrefetch -from django.core.exceptions import FieldError +from django.core.exceptions import FieldError, FieldFetchBlocked from django.db.models import Q, prefetch_related_objects +from django.db.models.fetch_modes import FETCH_PEERS, RAISE from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature from .models import ( @@ -780,6 +781,46 @@ class GenericRelationsTests(TestCase): self.platypus.latin_name, ) + def test_fetch_mode_fetch_peers(self): + TaggedItem.objects.bulk_create( + [ + TaggedItem(tag="lion", content_object=self.lion), + TaggedItem(tag="platypus", content_object=self.platypus), + TaggedItem(tag="quartz", content_object=self.quartz), + ] + ) + # Peers fetching should fetch all related peers GFKs at once which is + # one query per content type. + with self.assertNumQueries(1): + quartz_tag, platypus_tag, lion_tag = TaggedItem.objects.fetch_mode( + FETCH_PEERS + ).order_by("-pk")[:3] + with self.assertNumQueries(2): + self.assertEqual(lion_tag.content_object, self.lion) + with self.assertNumQueries(0): + self.assertEqual(platypus_tag.content_object, self.platypus) + self.assertEqual(quartz_tag.content_object, self.quartz) + # It should ignore already cached instances though. + with self.assertNumQueries(1): + quartz_tag, platypus_tag, lion_tag = TaggedItem.objects.fetch_mode( + FETCH_PEERS + ).order_by("-pk")[:3] + with self.assertNumQueries(2): + self.assertEqual(quartz_tag.content_object, self.quartz) + self.assertEqual(lion_tag.content_object, self.lion) + with self.assertNumQueries(0): + self.assertEqual(platypus_tag.content_object, self.platypus) + self.assertEqual(quartz_tag.content_object, self.quartz) + + def test_fetch_mode_raise(self): + TaggedItem.objects.create(tag="lion", content_object=self.lion) + tag = TaggedItem.objects.fetch_mode(RAISE).get(tag="yellow") + msg = "Fetching of TaggedItem.content_object blocked." + with self.assertRaisesMessage(FieldFetchBlocked, msg) as cm: + tag.content_object + self.assertIsNone(cm.exception.__cause__) + self.assertTrue(cm.exception.__suppress_context__) + class ProxyRelatedModelTest(TestCase): def test_default_behavior(self): diff --git a/tests/many_to_one/tests.py b/tests/many_to_one/tests.py index ac43c0da95..c5fa458570 100644 --- a/tests/many_to_one/tests.py +++ b/tests/many_to_one/tests.py @@ -1,8 +1,13 @@ import datetime from copy import deepcopy -from django.core.exceptions import FieldError, MultipleObjectsReturned +from django.core.exceptions import ( + FieldError, + FieldFetchBlocked, + MultipleObjectsReturned, +) from django.db import IntegrityError, models, transaction +from django.db.models import FETCH_PEERS, RAISE from django.test import TestCase from django.utils.translation import gettext_lazy @@ -916,3 +921,23 @@ class ManyToOneTests(TestCase): instances=countries, querysets=[City.objects.all(), City.objects.all()], ) + + def test_fetch_mode_fetch_peers_forward(self): + Article.objects.create( + headline="This is another test", + pub_date=datetime.date(2005, 7, 27), + reporter=self.r2, + ) + a1, a2 = Article.objects.fetch_mode(FETCH_PEERS) + with self.assertNumQueries(1): + a1.reporter + with self.assertNumQueries(0): + a2.reporter + + def test_fetch_mode_raise_forward(self): + a = Article.objects.fetch_mode(RAISE).get(pk=self.a.pk) + msg = "Fetching of Article.reporter blocked." + with self.assertRaisesMessage(FieldFetchBlocked, msg) as cm: + a.reporter + self.assertIsNone(cm.exception.__cause__) + self.assertTrue(cm.exception.__suppress_context__) diff --git a/tests/one_to_one/tests.py b/tests/one_to_one/tests.py index d9bcb5d4dc..da7bd992c0 100644 --- a/tests/one_to_one/tests.py +++ b/tests/one_to_one/tests.py @@ -1,4 +1,6 @@ +from django.core.exceptions import FieldFetchBlocked from django.db import IntegrityError, connection, transaction +from django.db.models import FETCH_PEERS, RAISE from django.test import TestCase from .models import ( @@ -619,3 +621,39 @@ class OneToOneTests(TestCase): instances=places, querysets=[Bar.objects.all(), Bar.objects.all()], ) + + def test_fetch_mode_fetch_peers_forward(self): + Restaurant.objects.create( + place=self.p2, serves_hot_dogs=True, serves_pizza=False + ) + r1, r2 = Restaurant.objects.fetch_mode(FETCH_PEERS) + with self.assertNumQueries(1): + r1.place + with self.assertNumQueries(0): + r2.place + + def test_fetch_mode_fetch_peers_reverse(self): + Restaurant.objects.create( + place=self.p2, serves_hot_dogs=True, serves_pizza=False + ) + p1, p2 = Place.objects.fetch_mode(FETCH_PEERS) + with self.assertNumQueries(1): + p1.restaurant + with self.assertNumQueries(0): + p2.restaurant + + def test_fetch_mode_raise_forward(self): + r = Restaurant.objects.fetch_mode(RAISE).get(pk=self.r1.pk) + msg = "Fetching of Restaurant.place blocked." + with self.assertRaisesMessage(FieldFetchBlocked, msg) as cm: + r.place + self.assertIsNone(cm.exception.__cause__) + self.assertTrue(cm.exception.__suppress_context__) + + def test_fetch_mode_raise_reverse(self): + p = Place.objects.fetch_mode(RAISE).get(pk=self.p1.pk) + msg = "Fetching of Place.restaurant blocked." + with self.assertRaisesMessage(FieldFetchBlocked, msg) as cm: + p.restaurant + self.assertIsNone(cm.exception.__cause__) + self.assertTrue(cm.exception.__suppress_context__) diff --git a/tests/prefetch_related/tests.py b/tests/prefetch_related/tests.py index 54b197ad83..6e4acdddf6 100644 --- a/tests/prefetch_related/tests.py +++ b/tests/prefetch_related/tests.py @@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db import NotSupportedError, connection from django.db.models import F, Prefetch, QuerySet, prefetch_related_objects +from django.db.models.fetch_modes import RAISE from django.db.models.query import get_prefetcher from django.db.models.sql import Query from django.test import ( @@ -107,6 +108,10 @@ class PrefetchRelatedTests(TestDataMixin, TestCase): normal_books = [a.first_book for a in Author.objects.all()] self.assertEqual(books, normal_books) + def test_fetch_mode_raise(self): + authors = list(Author.objects.fetch_mode(RAISE).prefetch_related("first_book")) + authors[0].first_book # No exception, already loaded + def test_foreignkey_reverse(self): with self.assertNumQueries(2): [ diff --git a/tests/raw_query/tests.py b/tests/raw_query/tests.py index 853b7ee20e..f66afbf28b 100644 --- a/tests/raw_query/tests.py +++ b/tests/raw_query/tests.py @@ -1,7 +1,8 @@ from datetime import date from decimal import Decimal -from django.core.exceptions import FieldDoesNotExist +from django.core.exceptions import FieldDoesNotExist, FieldFetchBlocked +from django.db.models import FETCH_PEERS, RAISE from django.db.models.query import RawQuerySet from django.test import TestCase, skipUnlessDBFeature @@ -158,6 +159,22 @@ class RawQueryTests(TestCase): books = Book.objects.all() self.assertSuccessfulRawQuery(Book, query, books) + def test_fk_fetch_mode_peers(self): + query = "SELECT * FROM raw_query_book" + books = list(Book.objects.fetch_mode(FETCH_PEERS).raw(query)) + with self.assertNumQueries(1): + books[0].author + books[1].author + + def test_fk_fetch_mode_raise(self): + query = "SELECT * FROM raw_query_book" + books = list(Book.objects.fetch_mode(RAISE).raw(query)) + msg = "Fetching of Book.author blocked." + with self.assertRaisesMessage(FieldFetchBlocked, msg) as cm: + books[0].author + self.assertIsNone(cm.exception.__cause__) + self.assertTrue(cm.exception.__suppress_context__) + def test_db_column_handler(self): """ Test of a simple raw query against a model containing a field with @@ -294,6 +311,23 @@ class RawQueryTests(TestCase): with self.assertRaisesMessage(FieldDoesNotExist, msg): list(Author.objects.raw(query)) + def test_missing_fields_fetch_mode_peers(self): + query = "SELECT id, first_name, dob FROM raw_query_author" + authors = list(Author.objects.fetch_mode(FETCH_PEERS).raw(query)) + with self.assertNumQueries(1): + authors[0].last_name + authors[1].last_name + + def test_missing_fields_fetch_mode_raise(self): + query = "SELECT id, first_name, dob FROM raw_query_author" + authors = list(Author.objects.fetch_mode(RAISE).raw(query)) + msg = "Fetching of Author.last_name blocked." + with self.assertRaisesMessage(FieldFetchBlocked, msg) as cm: + authors[0].last_name + self.assertIsNone(cm.exception.__cause__) + self.assertTrue(cm.exception.__suppress_context__) + self.assertTrue(cm.exception.__suppress_context__) + def test_annotations(self): query = ( "SELECT a.*, count(b.id) as book_count " From a321d961b03d47b7f0c2e21a2370fc9e74c1889b Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Fri, 5 Sep 2025 11:04:27 +0100 Subject: [PATCH 074/116] Refs #28586 -- Made fetch modes pickle as singletons. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change ensures that we don’t create new instances of fetch modes when pickling and unpickling, saving memory and preserving their singleton nature. --- django/db/models/fetch_modes.py | 9 +++++++++ tests/queryset_pickle/tests.py | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/django/db/models/fetch_modes.py b/django/db/models/fetch_modes.py index a22ccd8a23..2b5e6aa212 100644 --- a/django/db/models/fetch_modes.py +++ b/django/db/models/fetch_modes.py @@ -16,6 +16,9 @@ class FetchOne(FetchMode): def fetch(self, fetcher, instance): fetcher.fetch_one(instance) + def __reduce__(self): + return "FETCH_ONE" + FETCH_ONE = FetchOne() @@ -36,6 +39,9 @@ class FetchPeers(FetchMode): else: fetcher.fetch_one(instance) + def __reduce__(self): + return "FETCH_PEERS" + FETCH_PEERS = FetchPeers() @@ -48,5 +54,8 @@ class Raise(FetchMode): field_name = fetcher.field.name raise FieldFetchBlocked(f"Fetching of {klass}.{field_name} blocked.") from None + def __reduce__(self): + return "RAISE" + RAISE = Raise() diff --git a/tests/queryset_pickle/tests.py b/tests/queryset_pickle/tests.py index acdb582a0a..074a8ed550 100644 --- a/tests/queryset_pickle/tests.py +++ b/tests/queryset_pickle/tests.py @@ -351,6 +351,29 @@ class PickleabilityTestCase(TestCase): event.edition_set.create() self.assert_pickles(event.edition_set.order_by("event")) + def test_fetch_mode_fetch_one(self): + restored = pickle.loads(pickle.dumps(self.happening)) + self.assertIs(restored._state.fetch_mode, models.FETCH_ONE) + + def test_fetch_mode_fetch_peers(self): + Happening.objects.create() + objs = list(Happening.objects.fetch_mode(models.FETCH_PEERS)) + self.assertEqual(objs[0]._state.fetch_mode, models.FETCH_PEERS) + self.assertEqual(len(objs[0]._state.peers), 2) + + restored = pickle.loads(pickle.dumps(objs)) + + self.assertIs(restored[0]._state.fetch_mode, models.FETCH_PEERS) + # Peers not restored because weak references are not picklable. + self.assertEqual(restored[0]._state.peers, ()) + + def test_fetch_mode_raise(self): + objs = list(Happening.objects.fetch_mode(models.RAISE)) + self.assertEqual(objs[0]._state.fetch_mode, models.RAISE) + + restored = pickle.loads(pickle.dumps(objs)) + self.assertIs(restored[0]._state.fetch_mode, models.RAISE) + class InLookupTests(TestCase): @classmethod From 821619aa8771ef211c4c4922001efdf914201ca3 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Thu, 4 Sep 2025 18:14:51 +0100 Subject: [PATCH 075/116] Refs #28586 -- Simplified related descriptor get_queryset() methods. Modify these methods to accept an instance parameter which is clearer and allows us to set the instance hint earlier. --- .../db/models/fields/related_descriptors.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/django/db/models/fields/related_descriptors.py b/django/db/models/fields/related_descriptors.py index 2c8e59f1d9..7df96491a0 100644 --- a/django/db/models/fields/related_descriptors.py +++ b/django/db/models/fields/related_descriptors.py @@ -166,8 +166,10 @@ class ForwardManyToOneDescriptor: def is_cached(self, instance): return self.field.is_cached(instance) - def get_queryset(self, **hints): - return self.field.remote_field.model._base_manager.db_manager(hints=hints).all() + def get_queryset(self, *, instance): + return self.field.remote_field.model._base_manager.db_manager( + hints={"instance": instance} + ).all() def get_prefetch_querysets(self, instances, querysets=None): if querysets and len(querysets) != 1: @@ -175,8 +177,9 @@ class ForwardManyToOneDescriptor: "querysets argument of get_prefetch_querysets() should have a length " "of 1." ) - queryset = querysets[0] if querysets else self.get_queryset() - queryset._add_hints(instance=instances[0]) + queryset = ( + querysets[0] if querysets else self.get_queryset(instance=instances[0]) + ) rel_obj_attr = self.field.get_foreign_related_value instance_attr = self.field.get_local_related_value @@ -456,8 +459,10 @@ class ReverseOneToOneDescriptor: def is_cached(self, instance): return self.related.is_cached(instance) - def get_queryset(self, **hints): - return self.related.related_model._base_manager.db_manager(hints=hints).all() + def get_queryset(self, *, instance): + return self.related.related_model._base_manager.db_manager( + hints={"instance": instance} + ).all() def get_prefetch_querysets(self, instances, querysets=None): if querysets and len(querysets) != 1: @@ -465,8 +470,9 @@ class ReverseOneToOneDescriptor: "querysets argument of get_prefetch_querysets() should have a length " "of 1." ) - queryset = querysets[0] if querysets else self.get_queryset() - queryset._add_hints(instance=instances[0]) + queryset = ( + querysets[0] if querysets else self.get_queryset(instance=instances[0]) + ) rel_obj_attr = self.related.field.get_local_related_value instance_attr = self.related.field.get_foreign_related_value From 6dc9b04018032dccbb5ad8347f7ddf4341316166 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Mon, 14 Apr 2025 15:12:28 +0100 Subject: [PATCH 076/116] Refs #28586 -- Copied fetch modes to related objects. This change ensures that behavior and performance remain consistent when traversing relationships. --- django/contrib/contenttypes/fields.py | 14 ++++-- .../db/models/fields/related_descriptors.py | 7 ++- django/db/models/query.py | 22 ++++++--- docs/topics/db/fetch-modes.txt | 5 ++ tests/foreign_object/tests.py | 37 ++++++++++++++ tests/generic_relations/tests.py | 32 +++++++++++- tests/many_to_many/tests.py | 41 ++++++++++++++++ tests/many_to_one/tests.py | 49 +++++++++++++++++++ tests/model_inheritance_regress/tests.py | 17 +++++++ tests/one_to_one/tests.py | 38 ++++++++++++++ tests/prefetch_related/tests.py | 30 +++++++++++- tests/select_related/tests.py | 32 ++++++++++++ 12 files changed, 310 insertions(+), 14 deletions(-) diff --git a/django/contrib/contenttypes/fields.py b/django/contrib/contenttypes/fields.py index aa41eab370..62239dc715 100644 --- a/django/contrib/contenttypes/fields.py +++ b/django/contrib/contenttypes/fields.py @@ -201,11 +201,13 @@ class GenericForeignKeyDescriptor: for ct_id, fkeys in fk_dict.items(): if ct_id in custom_queryset_dict: # Return values from the custom queryset, if provided. - ret_val.extend(custom_queryset_dict[ct_id].filter(pk__in=fkeys)) + queryset = custom_queryset_dict[ct_id].filter(pk__in=fkeys) else: instance = instance_dict[ct_id] ct = self.field.get_content_type(id=ct_id, using=instance._state.db) - ret_val.extend(ct.get_all_objects_for_this_type(pk__in=fkeys)) + queryset = ct.get_all_objects_for_this_type(pk__in=fkeys) + + ret_val.extend(queryset.fetch_mode(instances[0]._state.fetch_mode)) # For doing the join in Python, we have to match both the FK val and # the content type, so we use a callable that returns a (fk, class) @@ -271,6 +273,8 @@ class GenericForeignKeyDescriptor: ) except ObjectDoesNotExist: pass + else: + rel_obj._state.fetch_mode = instance._state.fetch_mode self.field.set_cached_value(instance, rel_obj) def fetch_many(self, instances): @@ -636,7 +640,11 @@ def create_generic_related_manager(superclass, rel): Filter the queryset for the instance this manager is bound to. """ db = self._db or router.db_for_read(self.model, instance=self.instance) - return queryset.using(db).filter(**self.core_filters) + return ( + queryset.using(db) + .fetch_mode(self.instance._state.fetch_mode) + .filter(**self.core_filters) + ) def _remove_prefetched_objects(self): try: diff --git a/django/db/models/fields/related_descriptors.py b/django/db/models/fields/related_descriptors.py index 7df96491a0..4728233a6a 100644 --- a/django/db/models/fields/related_descriptors.py +++ b/django/db/models/fields/related_descriptors.py @@ -169,7 +169,7 @@ class ForwardManyToOneDescriptor: def get_queryset(self, *, instance): return self.field.remote_field.model._base_manager.db_manager( hints={"instance": instance} - ).all() + ).fetch_mode(instance._state.fetch_mode) def get_prefetch_querysets(self, instances, querysets=None): if querysets and len(querysets) != 1: @@ -398,6 +398,7 @@ class ForwardOneToOneDescriptor(ForwardManyToOneDescriptor): obj = rel_model(**kwargs) obj._state.adding = instance._state.adding obj._state.db = instance._state.db + obj._state.fetch_mode = instance._state.fetch_mode return obj return super().get_object(instance) @@ -462,7 +463,7 @@ class ReverseOneToOneDescriptor: def get_queryset(self, *, instance): return self.related.related_model._base_manager.db_manager( hints={"instance": instance} - ).all() + ).fetch_mode(instance._state.fetch_mode) def get_prefetch_querysets(self, instances, querysets=None): if querysets and len(querysets) != 1: @@ -740,6 +741,7 @@ def create_reverse_many_to_one_manager(superclass, rel): queryset._add_hints(instance=self.instance) if self._db: queryset = queryset.using(self._db) + queryset._fetch_mode = self.instance._state.fetch_mode queryset._defer_next_filter = True queryset = queryset.filter(**self.core_filters) for field in self.field.foreign_related_fields: @@ -1141,6 +1143,7 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): queryset._add_hints(instance=self.instance) if self._db: queryset = queryset.using(self._db) + queryset._fetch_mode = self.instance._state.fetch_mode queryset._defer_next_filter = True return queryset._next_is_sticky().filter(**self.core_filters) diff --git a/django/db/models/query.py b/django/db/models/query.py index 0811b90b5e..0a577f8c2d 100644 --- a/django/db/models/query.py +++ b/django/db/models/query.py @@ -90,6 +90,7 @@ class ModelIterable(BaseIterable): queryset = self.queryset db = queryset.db compiler = queryset.query.get_compiler(using=db) + fetch_mode = queryset._fetch_mode # Execute the query. This will also fill compiler.select, klass_info, # and annotations. results = compiler.execute_sql( @@ -106,7 +107,7 @@ class ModelIterable(BaseIterable): init_list = [ f[0].target.attname for f in select[model_fields_start:model_fields_end] ] - related_populators = get_related_populators(klass_info, select, db) + related_populators = get_related_populators(klass_info, select, db, fetch_mode) known_related_objects = [ ( field, @@ -124,7 +125,6 @@ class ModelIterable(BaseIterable): ) for field, related_objs in queryset._known_related_objects.items() ] - fetch_mode = queryset._fetch_mode peers = [] for row in compiler.results_iter(results): obj = model_cls.from_db( @@ -2787,8 +2787,9 @@ class RelatedPopulator: model instance. """ - def __init__(self, klass_info, select, db): + def __init__(self, klass_info, select, db, fetch_mode): self.db = db + self.fetch_mode = fetch_mode # Pre-compute needed attributes. The attributes are: # - model_cls: the possibly deferred model class to instantiate # - either: @@ -2841,7 +2842,9 @@ class RelatedPopulator: # relationship. Therefore checking for a single member of the primary # key is enough to determine if the referenced object exists or not. self.pk_idx = self.init_list.index(self.model_cls._meta.pk_fields[0].attname) - self.related_populators = get_related_populators(klass_info, select, self.db) + self.related_populators = get_related_populators( + klass_info, select, self.db, fetch_mode + ) self.local_setter = klass_info["local_setter"] self.remote_setter = klass_info["remote_setter"] @@ -2853,7 +2856,12 @@ class RelatedPopulator: if obj_data[self.pk_idx] is None: obj = None else: - obj = self.model_cls.from_db(self.db, self.init_list, obj_data) + obj = self.model_cls.from_db( + self.db, + self.init_list, + obj_data, + fetch_mode=self.fetch_mode, + ) for rel_iter in self.related_populators: rel_iter.populate(row, obj) self.local_setter(from_obj, obj) @@ -2861,10 +2869,10 @@ class RelatedPopulator: self.remote_setter(obj, from_obj) -def get_related_populators(klass_info, select, db): +def get_related_populators(klass_info, select, db, fetch_mode): iterators = [] related_klass_infos = klass_info.get("related_klass_infos", []) for rel_klass_info in related_klass_infos: - rel_cls = RelatedPopulator(rel_klass_info, select, db) + rel_cls = RelatedPopulator(rel_klass_info, select, db, fetch_mode) iterators.append(rel_cls) return iterators diff --git a/docs/topics/db/fetch-modes.txt b/docs/topics/db/fetch-modes.txt index e76bb28a59..da7a07a0d4 100644 --- a/docs/topics/db/fetch-modes.txt +++ b/docs/topics/db/fetch-modes.txt @@ -29,6 +29,11 @@ Fetch modes apply to: * Fields deferred with :meth:`.QuerySet.defer` or :meth:`.QuerySet.only` * :ref:`generic-relations` +Django copies the fetch mode of an instance to any related objects it fetches, +so the mode applies to a whole tree of relationships, not just the top-level +model in the initial ``QuerySet``. This copying is also done in related +managers, even though fetch modes don't affect such managers' queries. + Available modes =============== diff --git a/tests/foreign_object/tests.py b/tests/foreign_object/tests.py index 09fb47e771..233c596885 100644 --- a/tests/foreign_object/tests.py +++ b/tests/foreign_object/tests.py @@ -5,6 +5,7 @@ from operator import attrgetter from django.core.exceptions import FieldError, ValidationError from django.db import connection, models +from django.db.models import FETCH_PEERS from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature from django.test.utils import CaptureQueriesContext, isolate_apps from django.utils import translation @@ -603,6 +604,42 @@ class MultiColumnFKTests(TestCase): [m4], ) + def test_fetch_mode_copied_forward_fetching_one(self): + person = Person.objects.fetch_mode(FETCH_PEERS).get(pk=self.bob.pk) + self.assertEqual(person._state.fetch_mode, FETCH_PEERS) + self.assertEqual( + person.person_country._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_forward_fetching_many(self): + people = list(Person.objects.fetch_mode(FETCH_PEERS)) + person = people[0] + self.assertEqual(person._state.fetch_mode, FETCH_PEERS) + self.assertEqual( + person.person_country._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_reverse_fetching_one(self): + country = Country.objects.fetch_mode(FETCH_PEERS).get(pk=self.usa.pk) + self.assertEqual(country._state.fetch_mode, FETCH_PEERS) + person = country.person_set.get(pk=self.bob.pk) + self.assertEqual( + person._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_reverse_fetching_many(self): + countries = list(Country.objects.fetch_mode(FETCH_PEERS)) + country = countries[0] + self.assertEqual(country._state.fetch_mode, FETCH_PEERS) + person = country.person_set.earliest("pk") + self.assertEqual( + person._state.fetch_mode, + FETCH_PEERS, + ) + class TestModelCheckTests(SimpleTestCase): @isolate_apps("foreign_object") diff --git a/tests/generic_relations/tests.py b/tests/generic_relations/tests.py index 3de243d7b8..dceb8f4bae 100644 --- a/tests/generic_relations/tests.py +++ b/tests/generic_relations/tests.py @@ -813,7 +813,6 @@ class GenericRelationsTests(TestCase): self.assertEqual(quartz_tag.content_object, self.quartz) def test_fetch_mode_raise(self): - TaggedItem.objects.create(tag="lion", content_object=self.lion) tag = TaggedItem.objects.fetch_mode(RAISE).get(tag="yellow") msg = "Fetching of TaggedItem.content_object blocked." with self.assertRaisesMessage(FieldFetchBlocked, msg) as cm: @@ -821,6 +820,37 @@ class GenericRelationsTests(TestCase): self.assertIsNone(cm.exception.__cause__) self.assertTrue(cm.exception.__suppress_context__) + def test_fetch_mode_copied_forward_fetching_one(self): + tag = TaggedItem.objects.fetch_mode(FETCH_PEERS).get(tag="yellow") + self.assertEqual(tag.content_object, self.lion) + self.assertEqual( + tag.content_object._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_forward_fetching_many(self): + tags = list(TaggedItem.objects.fetch_mode(FETCH_PEERS).order_by("tag")) + tag = [t for t in tags if t.tag == "yellow"][0] + self.assertEqual(tag.content_object, self.lion) + self.assertEqual( + tag.content_object._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_reverse_fetching_one(self): + animal = Animal.objects.fetch_mode(FETCH_PEERS).get(pk=self.lion.pk) + self.assertEqual(animal._state.fetch_mode, FETCH_PEERS) + tag = animal.tags.get(tag="yellow") + self.assertEqual(tag._state.fetch_mode, FETCH_PEERS) + + def test_fetch_mode_copied_reverse_fetching_many(self): + animals = list(Animal.objects.fetch_mode(FETCH_PEERS)) + animal = animals[0] + self.assertEqual(animal._state.fetch_mode, FETCH_PEERS) + tags = list(animal.tags.all()) + tag = tags[0] + self.assertEqual(tag._state.fetch_mode, FETCH_PEERS) + class ProxyRelatedModelTest(TestCase): def test_default_behavior(self): diff --git a/tests/many_to_many/tests.py b/tests/many_to_many/tests.py index 34b7ffc67d..30fbde873e 100644 --- a/tests/many_to_many/tests.py +++ b/tests/many_to_many/tests.py @@ -1,6 +1,7 @@ from unittest import mock from django.db import connection, transaction +from django.db.models import FETCH_PEERS from django.test import TestCase, skipIfDBFeature, skipUnlessDBFeature from .models import ( @@ -589,6 +590,46 @@ class ManyToManyTests(TestCase): querysets=[Publication.objects.all(), Publication.objects.all()], ) + def test_fetch_mode_copied_forward_fetching_one(self): + a = Article.objects.fetch_mode(FETCH_PEERS).get(pk=self.a1.pk) + self.assertEqual(a._state.fetch_mode, FETCH_PEERS) + p = a.publications.earliest("pk") + self.assertEqual( + p._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_forward_fetching_many(self): + articles = list(Article.objects.fetch_mode(FETCH_PEERS)) + a = articles[0] + self.assertEqual(a._state.fetch_mode, FETCH_PEERS) + publications = list(a.publications.all()) + p = publications[0] + self.assertEqual( + p._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_reverse_fetching_one(self): + p1 = Publication.objects.fetch_mode(FETCH_PEERS).get(pk=self.p1.pk) + self.assertEqual(p1._state.fetch_mode, FETCH_PEERS) + a = p1.article_set.earliest("pk") + self.assertEqual( + a._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_reverse_fetching_many(self): + publications = list(Publication.objects.fetch_mode(FETCH_PEERS)) + p = publications[0] + self.assertEqual(p._state.fetch_mode, FETCH_PEERS) + articles = list(p.article_set.all()) + a = articles[0] + self.assertEqual( + a._state.fetch_mode, + FETCH_PEERS, + ) + class ManyToManyQueryTests(TestCase): """ diff --git a/tests/many_to_one/tests.py b/tests/many_to_one/tests.py index c5fa458570..4d2343e304 100644 --- a/tests/many_to_one/tests.py +++ b/tests/many_to_one/tests.py @@ -941,3 +941,52 @@ class ManyToOneTests(TestCase): a.reporter self.assertIsNone(cm.exception.__cause__) self.assertTrue(cm.exception.__suppress_context__) + + def test_fetch_mode_copied_forward_fetching_one(self): + a1 = Article.objects.fetch_mode(FETCH_PEERS).get() + self.assertEqual(a1._state.fetch_mode, FETCH_PEERS) + self.assertEqual( + a1.reporter._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_forward_fetching_many(self): + Article.objects.create( + headline="This is another test", + pub_date=datetime.date(2005, 7, 27), + reporter=self.r2, + ) + a1, a2 = Article.objects.fetch_mode(FETCH_PEERS) + self.assertEqual(a1._state.fetch_mode, FETCH_PEERS) + self.assertEqual( + a1.reporter._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_reverse_fetching_one(self): + r1 = Reporter.objects.fetch_mode(FETCH_PEERS).get(pk=self.r.pk) + self.assertEqual(r1._state.fetch_mode, FETCH_PEERS) + article = r1.article_set.get() + self.assertEqual( + article._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_reverse_fetching_many(self): + Article.objects.create( + headline="This is another test", + pub_date=datetime.date(2005, 7, 27), + reporter=self.r2, + ) + r1, r2 = Reporter.objects.fetch_mode(FETCH_PEERS) + self.assertEqual(r1._state.fetch_mode, FETCH_PEERS) + a1 = r1.article_set.get() + self.assertEqual( + a1._state.fetch_mode, + FETCH_PEERS, + ) + a2 = r2.article_set.get() + self.assertEqual( + a2._state.fetch_mode, + FETCH_PEERS, + ) diff --git a/tests/model_inheritance_regress/tests.py b/tests/model_inheritance_regress/tests.py index 3310497de1..adc2a22fc4 100644 --- a/tests/model_inheritance_regress/tests.py +++ b/tests/model_inheritance_regress/tests.py @@ -7,6 +7,7 @@ from operator import attrgetter from unittest import expectedFailure from django import forms +from django.db.models import FETCH_PEERS from django.test import TestCase from .models import ( @@ -600,6 +601,22 @@ class ModelInheritanceTest(TestCase): self.assertEqual(restaurant.place_ptr.restaurant, restaurant) self.assertEqual(restaurant.italianrestaurant, italian_restaurant) + def test_parent_access_copies_fetch_mode(self): + italian_restaurant = ItalianRestaurant.objects.create( + name="Mom's Spaghetti", + address="2131 Woodward Ave", + serves_hot_dogs=False, + serves_pizza=False, + serves_gnocchi=True, + ) + + # No queries are made when accessing the parent objects. + italian_restaurant = ItalianRestaurant.objects.fetch_mode(FETCH_PEERS).get( + pk=italian_restaurant.pk + ) + restaurant = italian_restaurant.restaurant_ptr + self.assertEqual(restaurant._state.fetch_mode, FETCH_PEERS) + def test_id_field_update_on_ancestor_change(self): place1 = Place.objects.create(name="House of Pasta", address="944 Fullerton") place2 = Place.objects.create(name="House of Pizza", address="954 Fullerton") diff --git a/tests/one_to_one/tests.py b/tests/one_to_one/tests.py index da7bd992c0..39f24d6b10 100644 --- a/tests/one_to_one/tests.py +++ b/tests/one_to_one/tests.py @@ -657,3 +657,41 @@ class OneToOneTests(TestCase): p.restaurant self.assertIsNone(cm.exception.__cause__) self.assertTrue(cm.exception.__suppress_context__) + + def test_fetch_mode_copied_forward_fetching_one(self): + r1 = Restaurant.objects.fetch_mode(FETCH_PEERS).get(pk=self.r1.pk) + self.assertEqual(r1._state.fetch_mode, FETCH_PEERS) + self.assertEqual( + r1.place._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_forward_fetching_many(self): + Restaurant.objects.create( + place=self.p2, serves_hot_dogs=True, serves_pizza=False + ) + r1, r2 = Restaurant.objects.fetch_mode(FETCH_PEERS) + self.assertEqual(r1._state.fetch_mode, FETCH_PEERS) + self.assertEqual( + r1.place._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_reverse_fetching_one(self): + p1 = Place.objects.fetch_mode(FETCH_PEERS).get(pk=self.p1.pk) + self.assertEqual(p1._state.fetch_mode, FETCH_PEERS) + self.assertEqual( + p1.restaurant._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_reverse_fetching_many(self): + Restaurant.objects.create( + place=self.p2, serves_hot_dogs=True, serves_pizza=False + ) + p1, p2 = Place.objects.fetch_mode(FETCH_PEERS) + self.assertEqual(p1._state.fetch_mode, FETCH_PEERS) + self.assertEqual( + p1.restaurant._state.fetch_mode, + FETCH_PEERS, + ) diff --git a/tests/prefetch_related/tests.py b/tests/prefetch_related/tests.py index 6e4acdddf6..bb6417b8ae 100644 --- a/tests/prefetch_related/tests.py +++ b/tests/prefetch_related/tests.py @@ -3,7 +3,13 @@ from unittest import mock from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db import NotSupportedError, connection -from django.db.models import F, Prefetch, QuerySet, prefetch_related_objects +from django.db.models import ( + FETCH_PEERS, + F, + Prefetch, + QuerySet, + prefetch_related_objects, +) from django.db.models.fetch_modes import RAISE from django.db.models.query import get_prefetcher from django.db.models.sql import Query @@ -108,6 +114,28 @@ class PrefetchRelatedTests(TestDataMixin, TestCase): normal_books = [a.first_book for a in Author.objects.all()] self.assertEqual(books, normal_books) + def test_fetch_mode_copied_fetching_one(self): + author = ( + Author.objects.fetch_mode(FETCH_PEERS) + .prefetch_related("first_book") + .get(pk=self.author1.pk) + ) + self.assertEqual(author._state.fetch_mode, FETCH_PEERS) + self.assertEqual( + author.first_book._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_fetching_many(self): + authors = list( + Author.objects.fetch_mode(FETCH_PEERS).prefetch_related("first_book") + ) + self.assertEqual(authors[0]._state.fetch_mode, FETCH_PEERS) + self.assertEqual( + authors[0].first_book._state.fetch_mode, + FETCH_PEERS, + ) + def test_fetch_mode_raise(self): authors = list(Author.objects.fetch_mode(RAISE).prefetch_related("first_book")) authors[0].first_book # No exception, already loaded diff --git a/tests/select_related/tests.py b/tests/select_related/tests.py index 68fe7a906f..41ed350cf3 100644 --- a/tests/select_related/tests.py +++ b/tests/select_related/tests.py @@ -1,4 +1,5 @@ from django.core.exceptions import FieldError +from django.db.models import FETCH_PEERS from django.test import SimpleTestCase, TestCase from .models import ( @@ -210,6 +211,37 @@ class SelectRelatedTests(TestCase): with self.assertRaisesMessage(TypeError, message): list(Species.objects.values_list("name").select_related("genus")) + def test_fetch_mode_copied_fetching_one(self): + fly = ( + Species.objects.fetch_mode(FETCH_PEERS) + .select_related("genus__family") + .get(name="melanogaster") + ) + self.assertEqual(fly._state.fetch_mode, FETCH_PEERS) + self.assertEqual( + fly.genus._state.fetch_mode, + FETCH_PEERS, + ) + self.assertEqual( + fly.genus.family._state.fetch_mode, + FETCH_PEERS, + ) + + def test_fetch_mode_copied_fetching_many(self): + specieses = list( + Species.objects.fetch_mode(FETCH_PEERS).select_related("genus__family") + ) + species = specieses[0] + self.assertEqual(species._state.fetch_mode, FETCH_PEERS) + self.assertEqual( + species.genus._state.fetch_mode, + FETCH_PEERS, + ) + self.assertEqual( + species.genus.family._state.fetch_mode, + FETCH_PEERS, + ) + class SelectRelatedValidationTests(SimpleTestCase): """ From e244d8bbb743eec413eb241139b6345885db39d9 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Thu, 4 Sep 2025 21:55:50 +0100 Subject: [PATCH 077/116] Refs #28586 - Copied fetch mode in QuerySet.create(). This change allows the pattern `MyModel.objects.fetch_mode(...).create(...)` to set the fetch mode for a new object. --- django/db/models/query.py | 1 + tests/basic/tests.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/django/db/models/query.py b/django/db/models/query.py index 0a577f8c2d..a2af672546 100644 --- a/django/db/models/query.py +++ b/django/db/models/query.py @@ -683,6 +683,7 @@ class QuerySet(AltersData): obj = self.model(**kwargs) self._for_write = True obj.save(force_insert=True, using=self.db) + obj._state.fetch_mode = self._fetch_mode return obj create.alters_data = True diff --git a/tests/basic/tests.py b/tests/basic/tests.py index 89aef16aef..ed655833e2 100644 --- a/tests/basic/tests.py +++ b/tests/basic/tests.py @@ -290,6 +290,13 @@ class ModelTest(TestCase): ) self.assertEqual(Article.objects.get(headline="Article 10"), a10) + def test_create_method_propagates_fetch_mode(self): + article = Article.objects.fetch_mode(models.FETCH_PEERS).create( + headline="Article 10", + pub_date=datetime(2005, 7, 31, 12, 30, 45), + ) + self.assertEqual(article._state.fetch_mode, models.FETCH_PEERS) + def test_year_lookup_edge_case(self): # Edge-case test: A year lookup should retrieve all objects in # the given year, including Jan. 1 and Dec. 31. From d980d68609448a4c85763fa34e471ff80540888b Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Thu, 16 Oct 2025 10:33:54 -0400 Subject: [PATCH 078/116] Bumped minimum isort version to 7.0.0. Added ignores relating to https://github.com/PyCQA/isort/issues/2352. --- .github/workflows/linters.yml | 2 +- .pre-commit-config.yaml | 2 +- django/db/backends/postgresql/compiler.py | 6 +++--- docs/internals/contributing/writing-code/coding-style.txt | 2 +- docs/internals/contributing/writing-code/unit-tests.txt | 2 +- tests/contenttypes_tests/test_views.py | 6 +++--- tox.ini | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 52f78ab5d6..9a70cb03b7 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -45,7 +45,7 @@ jobs: uses: actions/setup-python@v6 with: python-version: '3.13' - - run: python -m pip install "isort<6" + - run: python -m pip install isort - name: isort # Pinned to v3.0.0. uses: liskin/gh-problem-matcher-wrap@e7b7beaaafa52524748b31a381160759d68d61fb diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e1d8cec10b..f2a9217d6c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: files: 'docs/.*\.txt$' args: ["--rst-literal-block"] - repo: https://github.com/PyCQA/isort - rev: 5.13.2 + rev: 7.0.0 hooks: - id: isort - repo: https://github.com/PyCQA/flake8 diff --git a/django/db/backends/postgresql/compiler.py b/django/db/backends/postgresql/compiler.py index 344773fb7a..a07ae3ea92 100644 --- a/django/db/backends/postgresql/compiler.py +++ b/django/db/backends/postgresql/compiler.py @@ -1,10 +1,10 @@ -from django.db.models.sql.compiler import ( +from django.db.models.sql.compiler import ( # isort:skip SQLAggregateCompiler, SQLCompiler, SQLDeleteCompiler, + SQLInsertCompiler as BaseSQLInsertCompiler, + SQLUpdateCompiler, ) -from django.db.models.sql.compiler import SQLInsertCompiler as BaseSQLInsertCompiler -from django.db.models.sql.compiler import SQLUpdateCompiler __all__ = [ "SQLAggregateCompiler", diff --git a/docs/internals/contributing/writing-code/coding-style.txt b/docs/internals/contributing/writing-code/coding-style.txt index 72429492cd..499866defa 100644 --- a/docs/internals/contributing/writing-code/coding-style.txt +++ b/docs/internals/contributing/writing-code/coding-style.txt @@ -137,7 +137,7 @@ Imports .. console:: - $ python -m pip install "isort >= 5.1.0" + $ python -m pip install "isort >= 7.0.0" $ isort . This runs ``isort`` recursively from your current directory, modifying any diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt index 974b4861d5..acbf68a7de 100644 --- a/docs/internals/contributing/writing-code/unit-tests.txt +++ b/docs/internals/contributing/writing-code/unit-tests.txt @@ -83,7 +83,7 @@ environments can be seen as follows: blacken-docs flake8>=3.7.0 docs - isort>=5.1.0 + isort>=7.0.0 lint-docs Testing other Python versions and database backends diff --git a/tests/contenttypes_tests/test_views.py b/tests/contenttypes_tests/test_views.py index bec79105c4..eb3ab0a92d 100644 --- a/tests/contenttypes_tests/test_views.py +++ b/tests/contenttypes_tests/test_views.py @@ -8,7 +8,7 @@ from django.contrib.sites.shortcuts import get_current_site from django.http import Http404, HttpRequest from django.test import TestCase, override_settings -from .models import ( +from .models import ( # isort:skip Article, Author, FooWithBrokenAbsoluteUrl, @@ -17,9 +17,9 @@ from .models import ( ModelWithM2MToSite, ModelWithNullFKToSite, SchemeIncludedURL, + Site as MockSite, + UUIDModel, ) -from .models import Site as MockSite -from .models import UUIDModel @override_settings(ROOT_URLCONF="contenttypes_tests.urls") diff --git a/tox.ini b/tox.ini index ec90ceb1e4..4f1274a266 100644 --- a/tox.ini +++ b/tox.ini @@ -75,7 +75,7 @@ commands = [testenv:isort] basepython = python3 usedevelop = false -deps = isort >= 5.1.0 +deps = isort >= 7.0.0 changedir = {toxinidir} commands = isort --check-only --diff django tests scripts From f715bc8990b5b8a1df948c2b71e8edbdda47e7db Mon Sep 17 00:00:00 2001 From: aj2s <72272843+aj2s@users.noreply.github.com> Date: Fri, 17 Oct 2025 07:20:23 -0700 Subject: [PATCH 079/116] Fixed #36669 -- Doc'd that negative indexes are not supported in F() slices. --- docs/ref/models/expressions.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ref/models/expressions.txt b/docs/ref/models/expressions.txt index fc02f08f0d..b542fe7d71 100644 --- a/docs/ref/models/expressions.txt +++ b/docs/ref/models/expressions.txt @@ -182,8 +182,8 @@ Slicing ``F()`` expressions For string-based fields, text-based fields, and :class:`~django.contrib.postgres.fields.ArrayField`, you can use Python's -array-slicing syntax. The indices are 0-based and the ``step`` argument to -``slice`` is not supported. For example: +array-slicing syntax. The indices are 0-based. The ``step`` argument to +``slice`` and negative indexing are not supported. For example: .. code-block:: pycon From 2d9c194d5a0d9ae746e16ee5f641e30d544dc31b Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Fri, 17 Oct 2025 15:34:05 +0200 Subject: [PATCH 080/116] Refs #35844 -- Relaxed GEOSIOTest.test02_wktwriter() test assertion. --- tests/gis_tests/geos_tests/test_io.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/gis_tests/geos_tests/test_io.py b/tests/gis_tests/geos_tests/test_io.py index 14646ce385..419ecfc3b7 100644 --- a/tests/gis_tests/geos_tests/test_io.py +++ b/tests/gis_tests/geos_tests/test_io.py @@ -41,10 +41,7 @@ class GEOSIOTest(SimpleTestCase): def test02_wktwriter(self): # Creating a WKTWriter instance, testing its ptr property. wkt_w = WKTWriter() - msg = ( - "Incompatible pointer type: " - "." - ) + msg = "Incompatible pointer type: " with self.assertRaisesMessage(TypeError, msg): wkt_w.ptr = WKTReader.ptr_type() From 56977b466c33ca3da14a1ed2609172425a76a34e Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Mon, 13 Oct 2025 16:34:26 +0200 Subject: [PATCH 081/116] Refs #35844 -- Doc'd Python 3.14 compatibility. --- .github/workflows/docs.yml | 6 +++--- .github/workflows/linters.yml | 4 ++-- .github/workflows/postgis.yml | 2 +- .github/workflows/schedule_tests.yml | 10 +++++----- .github/workflows/screenshots.yml | 2 +- .github/workflows/selenium.yml | 4 ++-- .github/workflows/tests.yml | 2 +- docs/faq/install.txt | 4 ++-- docs/howto/windows.txt | 4 ++-- docs/intro/reusable-apps.txt | 1 + docs/releases/5.2.8.txt | 3 ++- docs/releases/5.2.txt | 5 +++-- docs/releases/6.0.txt | 4 ++-- pyproject.toml | 1 + tests/mail/tests.py | 2 +- tests/requirements/py3.txt | 2 +- tox.ini | 2 +- 17 files changed, 31 insertions(+), 27 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 90731ebcfc..6e4a9cdd1b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,7 +29,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' cache: 'pip' cache-dependency-path: 'docs/requirements.txt' - run: python -m pip install -r docs/requirements.txt @@ -47,7 +47,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' - run: python -m pip install blacken-docs - name: Build docs run: | @@ -68,7 +68,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' - run: python -m pip install sphinx-lint - name: Build docs run: | diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 9a70cb03b7..b5359efc3d 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -27,7 +27,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' - run: python -m pip install flake8 - name: flake8 # Pinned to v3.0.0. @@ -44,7 +44,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' - run: python -m pip install isort - name: isort # Pinned to v3.0.0. diff --git a/.github/workflows/postgis.yml b/.github/workflows/postgis.yml index 7976fdc03d..e20735233b 100644 --- a/.github/workflows/postgis.yml +++ b/.github/workflows/postgis.yml @@ -42,7 +42,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' cache: 'pip' cache-dependency-path: 'tests/requirements/py3.txt' - name: Update apt repo diff --git a/.github/workflows/schedule_tests.yml b/.github/workflows/schedule_tests.yml index 6ac72e24bb..402659b338 100644 --- a/.github/workflows/schedule_tests.yml +++ b/.github/workflows/schedule_tests.yml @@ -18,7 +18,7 @@ jobs: python-version: - '3.12' - '3.13' - - '3.14-dev' + - '3.14' name: Windows, SQLite, Python ${{ matrix.python-version }} continue-on-error: true steps: @@ -45,7 +45,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' cache: 'pip' - name: Install libmemcached-dev for pylibmc run: sudo apt-get install libmemcached-dev @@ -86,7 +86,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' cache: 'pip' cache-dependency-path: 'tests/requirements/py3.txt' - name: Install libmemcached-dev for pylibmc @@ -122,7 +122,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' cache: 'pip' cache-dependency-path: 'tests/requirements/py3.txt' - name: Install libmemcached-dev for pylibmc @@ -167,7 +167,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' cache: 'pip' cache-dependency-path: 'tests/requirements/py3.txt' - name: Install libmemcached-dev for pylibmc diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index c29cfc9eed..239b6958d8 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' cache: 'pip' cache-dependency-path: 'tests/requirements/py3.txt' - name: Install and upgrade packaging tools diff --git a/.github/workflows/selenium.yml b/.github/workflows/selenium.yml index c5e22d70c3..b9a573e37b 100644 --- a/.github/workflows/selenium.yml +++ b/.github/workflows/selenium.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' cache: 'pip' cache-dependency-path: 'tests/requirements/py3.txt' - name: Install libmemcached-dev for pylibmc @@ -61,7 +61,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.14' cache: 'pip' cache-dependency-path: 'tests/requirements/py3.txt' - name: Install libmemcached-dev for pylibmc diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index eb0966e7a2..9428a9de0c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,7 @@ jobs: strategy: matrix: python-version: - - '3.13' + - '3.14' name: Windows, SQLite, Python ${{ matrix.python-version }} steps: - name: Checkout diff --git a/docs/faq/install.txt b/docs/faq/install.txt index dcd7bcfdf5..6f49bbe22b 100644 --- a/docs/faq/install.txt +++ b/docs/faq/install.txt @@ -53,8 +53,8 @@ Django version Python versions 4.2 3.8, 3.9, 3.10, 3.11, 3.12 (added in 4.2.8) 5.0 3.10, 3.11, 3.12 5.1 3.10, 3.11, 3.12, 3.13 (added in 5.1.3) -5.2 3.10, 3.11, 3.12, 3.13 -6.0 3.12, 3.13 +5.2 3.10, 3.11, 3.12, 3.13, 3.14 (added in 5.2.8) +6.0 3.12, 3.13, 3.14 6.1 3.12, 3.13, 3.14 ============== =============== diff --git a/docs/howto/windows.txt b/docs/howto/windows.txt index 235b18a24f..63e497be04 100644 --- a/docs/howto/windows.txt +++ b/docs/howto/windows.txt @@ -2,7 +2,7 @@ How to install Django on Windows ================================ -This document will guide you through installing Python 3.13 and Django on +This document will guide you through installing Python 3.14 and Django on Windows. It also provides instructions for setting up a virtual environment, which makes it easier to work on Python projects. This is meant as a beginner's guide for users working on Django projects and does not reflect how Django @@ -18,7 +18,7 @@ Install Python ============== Django is a Python web framework, thus requiring Python to be installed on your -machine. At the time of writing, Python 3.13 is the latest version. +machine. At the time of writing, Python 3.14 is the latest version. To install Python on your machine go to https://www.python.org/downloads/. The website should offer you a download button for the latest Python version. diff --git a/docs/intro/reusable-apps.txt b/docs/intro/reusable-apps.txt index c82a2b456e..627cf3292e 100644 --- a/docs/intro/reusable-apps.txt +++ b/docs/intro/reusable-apps.txt @@ -235,6 +235,7 @@ this. For a small app like polls, this process isn't too difficult. "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", ] diff --git a/docs/releases/5.2.8.txt b/docs/releases/5.2.8.txt index dc750e4636..4151012387 100644 --- a/docs/releases/5.2.8.txt +++ b/docs/releases/5.2.8.txt @@ -4,7 +4,8 @@ Django 5.2.8 release notes *Expected November 5, 2025* -Django 5.2.8 fixes several bugs in 5.2.7. +Django 5.2.8 fixes several bugs in 5.2.7 and adds compatibility with Python +3.14. Bugfixes ======== diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt index fa005bd550..728218cb07 100644 --- a/docs/releases/5.2.txt +++ b/docs/releases/5.2.txt @@ -23,8 +23,9 @@ end in April 2026. Python compatibility ==================== -Django 5.2 supports Python 3.10, 3.11, 3.12, and 3.13. We **highly recommend** -and only officially support the latest release of each series. +Django 5.2 supports Python 3.10, 3.11, 3.12, 3.13, and 3.14 (as of 5.2.8). We +**highly recommend** and only officially support the latest release of each +series. .. _whats-new-5.2: diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index fd30c66121..0c9da42cc6 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -18,8 +18,8 @@ project. Python compatibility ==================== -Django 6.0 supports Python 3.12 and 3.13. We **highly recommend**, and only -officially support, the latest release of each series. +Django 6.0 supports Python 3.12, 3.13, and 3.14. We **highly recommend**, and +only officially support, the latest release of each series. The Django 5.2.x series is the last to support Python 3.10 and 3.11. diff --git a/pyproject.toml b/pyproject.toml index 3fc10a0131..38a4457c0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ classifiers = [ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI", diff --git a/tests/mail/tests.py b/tests/mail/tests.py index f1d7fcf43e..1dba83eb8e 100644 --- a/tests/mail/tests.py +++ b/tests/mail/tests.py @@ -258,7 +258,7 @@ class MailTests(MailTestsMixin, SimpleTestCase): `surrogateescape`. Following https://github.com/python/cpython/issues/76511, newer - versions of Python (3.12.3 and 3.13) ensure that a message's + versions of Python (3.12.3 and 3.13+) ensure that a message's payload is encoded with the provided charset and `surrogateescape` is used as the error handling strategy. diff --git a/tests/requirements/py3.txt b/tests/requirements/py3.txt index 1a16cc0440..76a017aab4 100644 --- a/tests/requirements/py3.txt +++ b/tests/requirements/py3.txt @@ -7,7 +7,7 @@ docutils >= 0.19 geoip2 >= 4.8.0 jinja2 >= 2.11.0 numpy >= 1.26.0 -Pillow >= 10.1.0; sys.platform != 'win32' or python_version < '3.14' +Pillow >= 10.1.0 # pylibmc/libmemcached can't be built on Windows. pylibmc; sys_platform != 'win32' pymemcache >= 3.4.0 diff --git a/tox.ini b/tox.ini index 4f1274a266..8d4698f084 100644 --- a/tox.ini +++ b/tox.ini @@ -27,7 +27,7 @@ setenv = PYTHONDONTWRITEBYTECODE=1 deps = -e . - py{3,312,313}: -rtests/requirements/py3.txt + py{3,312,313,314}: -rtests/requirements/py3.txt postgres: -rtests/requirements/postgres.txt mysql: -rtests/requirements/mysql.txt oracle: -rtests/requirements/oracle.txt From b1e0262c9f9d11eae6230b51c5aa5d71122d5f05 Mon Sep 17 00:00:00 2001 From: Segni Mekonnen Date: Tue, 14 Oct 2025 15:28:39 -0500 Subject: [PATCH 082/116] Fixed #36665 -- Improved manager usage guidance in docs/topics/db/optimization.txt. --- docs/topics/db/optimization.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/topics/db/optimization.txt b/docs/topics/db/optimization.txt index 3be0bd2cb5..e03c9c6354 100644 --- a/docs/topics/db/optimization.txt +++ b/docs/topics/db/optimization.txt @@ -226,8 +226,9 @@ and :meth:`~django.db.models.query.QuerySet.prefetch_related`. Understand their documentation thoroughly and apply them where needed. It may be useful to apply these methods in :doc:`managers and default managers -`. Be aware when your manager is and is not used; -sometimes this is tricky so don't make assumptions. +`. Be aware when your manager is and is not used; for +example, related object access :ref:`uses the base manager +` rather than the default manager. Use ``prefetch_related_objects()`` ---------------------------------- From 0c487aa3a7b2417481bf48c1e5355c855873e210 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Sat, 18 Oct 2025 15:03:50 +0200 Subject: [PATCH 083/116] Fixed #21961 -- Added support for database-level delete options for ForeignKey. Thanks Simon Charette for pair programming. Co-authored-by: Nick Stefan Co-authored-by: Akash Kumar Sen <71623442+Akash-Kumar-Sen@users.noreply.github.com> Co-authored-by: Simon Charette --- AUTHORS | 1 + django/contrib/admin/utils.py | 11 +- django/contrib/contenttypes/fields.py | 11 + .../commands/remove_stale_contenttypes.py | 12 +- django/db/backends/base/features.py | 3 + django/db/backends/base/operations.py | 10 + django/db/backends/base/schema.py | 12 +- django/db/backends/mysql/features.py | 1 + django/db/backends/mysql/schema.py | 2 +- django/db/backends/oracle/features.py | 1 + django/db/backends/oracle/schema.py | 3 +- django/db/backends/postgresql/schema.py | 4 +- django/db/backends/sqlite3/schema.py | 3 +- django/db/migrations/serializer.py | 8 + django/db/models/__init__.py | 6 + django/db/models/base.py | 26 ++- django/db/models/deletion.py | 39 +++- django/db/models/fields/__init__.py | 2 - django/db/models/fields/related.py | 99 +++++++-- docs/ref/checks.txt | 16 +- docs/ref/models/fields.txt | 53 ++++- docs/ref/models/instances.txt | 10 +- docs/ref/models/querysets.txt | 29 ++- docs/releases/6.1.txt | 15 ++ tests/admin_utils/models.py | 10 +- tests/admin_utils/tests.py | 25 ++- tests/contenttypes_tests/test_checks.py | 21 ++ tests/delete/models.py | 49 +++++ tests/delete/tests.py | 51 +++++ tests/invalid_models_tests/test_models.py | 61 ++++++ .../test_relative_fields.py | 198 +++++++++++++++++- tests/migrations/test_writer.py | 17 ++ tests/schema/tests.py | 96 ++++++++- 33 files changed, 838 insertions(+), 67 deletions(-) diff --git a/AUTHORS b/AUTHORS index 4204dc9f2b..be55e379c2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -791,6 +791,7 @@ answer newbie questions, and generally made Django that much better: Nick Presta Nick Sandford Nick Sarbicki + Nick Stefan Niclas Olofsson Nicola Larosa Nicolas Lara diff --git a/django/contrib/admin/utils.py b/django/contrib/admin/utils.py index 74bd571e56..8263b6f9e2 100644 --- a/django/contrib/admin/utils.py +++ b/django/contrib/admin/utils.py @@ -184,8 +184,8 @@ def get_deleted_objects(objs, request, admin_site): class NestedObjects(Collector): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, *args, force_collection=True, **kwargs): + super().__init__(*args, force_collection=force_collection, **kwargs) self.edges = {} # {from_instance: [to_instances]} self.protected = set() self.model_objs = defaultdict(set) @@ -242,13 +242,6 @@ class NestedObjects(Collector): roots.extend(self._nested(root, seen, format_callback)) return roots - def can_fast_delete(self, *args, **kwargs): - """ - We always want to load the objects into memory so that we can display - them to the user in confirm page. - """ - return False - def model_format_dict(obj): """ diff --git a/django/contrib/contenttypes/fields.py b/django/contrib/contenttypes/fields.py index 62239dc715..300fec4289 100644 --- a/django/contrib/contenttypes/fields.py +++ b/django/contrib/contenttypes/fields.py @@ -10,6 +10,7 @@ from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist from django.db import DEFAULT_DB_ALIAS, models, router, transaction from django.db.models import DO_NOTHING, ForeignObject, ForeignObjectRel from django.db.models.base import ModelBase, make_foreign_order_accessors +from django.db.models.deletion import DatabaseOnDelete from django.db.models.fields import Field from django.db.models.fields.mixins import FieldCacheMixin from django.db.models.fields.related import ( @@ -139,6 +140,16 @@ class GenericForeignKey(FieldCacheMixin, Field): id="contenttypes.E004", ) ] + elif isinstance(field.remote_field.on_delete, DatabaseOnDelete): + return [ + checks.Error( + f"'{self.model._meta.object_name}.{self.ct_field}' cannot use " + "the database-level on_delete variant.", + hint="Change the on_delete rule to the non-database variant.", + obj=self, + id="contenttypes.E006", + ) + ] else: return [] diff --git a/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py b/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py index 27aaf1d51b..d97a7dec30 100644 --- a/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py +++ b/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py @@ -61,7 +61,9 @@ class Command(BaseCommand): ct_info.append( " - Content type for %s.%s" % (ct.app_label, ct.model) ) - collector = NoFastDeleteCollector(using=using, origin=ct) + collector = Collector( + using=using, origin=ct, force_collection=True + ) collector.collect([ct]) for obj_type, objs in collector.data.items(): @@ -103,11 +105,3 @@ class Command(BaseCommand): else: if verbosity >= 2: self.stdout.write("Stale content types remain.") - - -class NoFastDeleteCollector(Collector): - def can_fast_delete(self, *args, **kwargs): - """ - Always load related objects to display them when showing confirmation. - """ - return False diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py index 0c79e5c133..2ada5177be 100644 --- a/django/db/backends/base/features.py +++ b/django/db/backends/base/features.py @@ -390,6 +390,9 @@ class BaseDatabaseFeatures: # subqueries? supports_tuple_comparison_against_subquery = True + # Does the backend support DEFAULT as delete option? + supports_on_delete_db_default = True + # Collation names for use by the Django test suite. test_collations = { "ci": None, # Case-insensitive. diff --git a/django/db/backends/base/operations.py b/django/db/backends/base/operations.py index 9822a7fbb1..e345701438 100644 --- a/django/db/backends/base/operations.py +++ b/django/db/backends/base/operations.py @@ -254,6 +254,16 @@ class BaseDatabaseOperations: if sql ) + def fk_on_delete_sql(self, operation): + """ + Return the SQL to make an ON DELETE statement. + """ + if operation in ["CASCADE", "SET NULL", "SET DEFAULT"]: + return f" ON DELETE {operation}" + if operation == "": + return "" + raise NotImplementedError(f"ON DELETE {operation} is not supported.") + def bulk_insert_sql(self, fields, placeholder_rows): placeholder_rows_sql = (", ".join(row) for row in placeholder_rows) values_sql = ", ".join([f"({sql})" for sql in placeholder_rows_sql]) diff --git a/django/db/backends/base/schema.py b/django/db/backends/base/schema.py index 96d555f862..1f27d6a0d4 100644 --- a/django/db/backends/base/schema.py +++ b/django/db/backends/base/schema.py @@ -121,7 +121,7 @@ class BaseDatabaseSchemaEditor: sql_create_fk = ( "ALTER TABLE %(table)s ADD CONSTRAINT %(name)s FOREIGN KEY (%(column)s) " - "REFERENCES %(to_table)s (%(to_column)s)%(deferrable)s" + "REFERENCES %(to_table)s (%(to_column)s)%(on_delete_db)s%(deferrable)s" ) sql_create_inline_fk = None sql_create_column_inline_fk = None @@ -241,6 +241,7 @@ class BaseDatabaseSchemaEditor: definition += " " + self.sql_create_inline_fk % { "to_table": self.quote_name(to_table), "to_column": self.quote_name(to_column), + "on_delete_db": self._create_on_delete_sql(model, field), } elif self.connection.features.supports_foreign_keys: self.deferred_sql.append( @@ -759,6 +760,7 @@ class BaseDatabaseSchemaEditor: "to_table": self.quote_name(to_table), "to_column": self.quote_name(to_column), "deferrable": self.connection.ops.deferrable_sql(), + "on_delete_db": self._create_on_delete_sql(model, field), } # Otherwise, add FK constraints later. else: @@ -1628,6 +1630,13 @@ class BaseDatabaseSchemaEditor: new_name=self.quote_name(new_name), ) + def _create_on_delete_sql(self, model, field): + remote_field = field.remote_field + try: + return remote_field.on_delete.on_delete_sql(self) + except AttributeError: + return "" + def _index_columns(self, table, columns, col_suffixes, opclasses): return Columns(table, columns, self.quote_name, col_suffixes=col_suffixes) @@ -1740,6 +1749,7 @@ class BaseDatabaseSchemaEditor: to_table=to_table, to_column=to_column, deferrable=deferrable, + on_delete_db=self._create_on_delete_sql(model, field), ) def _fk_constraint_name(self, model, field, suffix): diff --git a/django/db/backends/mysql/features.py b/django/db/backends/mysql/features.py index 24ecc0d80b..4be20b92ac 100644 --- a/django/db/backends/mysql/features.py +++ b/django/db/backends/mysql/features.py @@ -44,6 +44,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): SET V_I = P_I; END; """ + supports_on_delete_db_default = False # Neither MySQL nor MariaDB support partial indexes. supports_partial_indexes = False # COLLATE must be wrapped in parentheses because MySQL treats COLLATE as an diff --git a/django/db/backends/mysql/schema.py b/django/db/backends/mysql/schema.py index a4dba0ad39..ab388754ed 100644 --- a/django/db/backends/mysql/schema.py +++ b/django/db/backends/mysql/schema.py @@ -14,7 +14,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): sql_delete_unique = "ALTER TABLE %(table)s DROP INDEX %(name)s" sql_create_column_inline_fk = ( ", ADD CONSTRAINT %(name)s FOREIGN KEY (%(column)s) " - "REFERENCES %(to_table)s(%(to_column)s)" + "REFERENCES %(to_table)s(%(to_column)s)%(on_delete_db)s" ) sql_delete_fk = "ALTER TABLE %(table)s DROP FOREIGN KEY %(name)s" diff --git a/django/db/backends/oracle/features.py b/django/db/backends/oracle/features.py index e87f495e5c..c07d9f1ed0 100644 --- a/django/db/backends/oracle/features.py +++ b/django/db/backends/oracle/features.py @@ -78,6 +78,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): supports_json_field_contains = False supports_json_negative_indexing = False supports_collation_on_textfield = False + supports_on_delete_db_default = False test_now_utc_template = "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'" django_test_expected_failures = { # A bug in Django/oracledb with respect to string handling (#23843). diff --git a/django/db/backends/oracle/schema.py b/django/db/backends/oracle/schema.py index 48a048575d..13fa7220ce 100644 --- a/django/db/backends/oracle/schema.py +++ b/django/db/backends/oracle/schema.py @@ -20,7 +20,8 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): sql_alter_column_no_default_null = sql_alter_column_no_default sql_create_column_inline_fk = ( - "CONSTRAINT %(name)s REFERENCES %(to_table)s(%(to_column)s)%(deferrable)s" + "CONSTRAINT %(name)s REFERENCES %(to_table)s(%(to_column)s)%(on_delete_db)" + "s%(deferrable)s" ) sql_delete_table = "DROP TABLE %(table)s CASCADE CONSTRAINTS" sql_create_index = "CREATE INDEX %(name)s ON %(table)s (%(columns)s)%(extra)s" diff --git a/django/db/backends/postgresql/schema.py b/django/db/backends/postgresql/schema.py index 1d36696fd3..7dd9161687 100644 --- a/django/db/backends/postgresql/schema.py +++ b/django/db/backends/postgresql/schema.py @@ -28,8 +28,8 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): # Setting the constraint to IMMEDIATE to allow changing data in the same # transaction. sql_create_column_inline_fk = ( - "CONSTRAINT %(name)s REFERENCES %(to_table)s(%(to_column)s)%(deferrable)s" - "; SET CONSTRAINTS %(namespace)s%(name)s IMMEDIATE" + "CONSTRAINT %(name)s REFERENCES %(to_table)s(%(to_column)s)%(on_delete_db)s" + "%(deferrable)s; SET CONSTRAINTS %(namespace)s%(name)s IMMEDIATE" ) # Setting the constraint to IMMEDIATE runs any deferred checks to allow # dropping it in the same transaction. diff --git a/django/db/backends/sqlite3/schema.py b/django/db/backends/sqlite3/schema.py index 077a53bf55..223a70947b 100644 --- a/django/db/backends/sqlite3/schema.py +++ b/django/db/backends/sqlite3/schema.py @@ -13,7 +13,8 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): sql_delete_table = "DROP TABLE %(table)s" sql_create_fk = None sql_create_inline_fk = ( - "REFERENCES %(to_table)s (%(to_column)s) DEFERRABLE INITIALLY DEFERRED" + "REFERENCES %(to_table)s (%(to_column)s)%(on_delete_db)s DEFERRABLE INITIALLY " + "DEFERRED" ) sql_create_column_inline_fk = sql_create_inline_fk sql_create_unique = "CREATE UNIQUE INDEX %(name)s ON %(table)s (%(columns)s)" diff --git a/django/db/migrations/serializer.py b/django/db/migrations/serializer.py index 8366fb0a42..013bb0fb00 100644 --- a/django/db/migrations/serializer.py +++ b/django/db/migrations/serializer.py @@ -16,6 +16,7 @@ from django.conf import SettingsReference from django.db import models from django.db.migrations.operations.base import Operation from django.db.migrations.utils import COMPILED_REGEX_TYPE, RegexObject +from django.db.models.deletion import DatabaseOnDelete from django.utils.functional import LazyObject, Promise from django.utils.version import get_docs_version @@ -71,6 +72,12 @@ class ChoicesSerializer(BaseSerializer): return serializer_factory(self.value.value).serialize() +class DatabaseOnDeleteSerializer(BaseSerializer): + def serialize(self): + path = self.value.__class__.__module__ + return f"{path}.{self.value.__name__}", {f"import {path}"} + + class DateTimeSerializer(BaseSerializer): """For datetime.*, except datetime.datetime.""" @@ -363,6 +370,7 @@ class Serializer: pathlib.PurePath: PathSerializer, os.PathLike: PathLikeSerializer, zoneinfo.ZoneInfo: ZoneInfoSerializer, + DatabaseOnDelete: DatabaseOnDeleteSerializer, } @classmethod diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py index f15ddecfaa..757e098317 100644 --- a/django/db/models/__init__.py +++ b/django/db/models/__init__.py @@ -6,6 +6,9 @@ from django.db.models.constraints import * # NOQA from django.db.models.constraints import __all__ as constraints_all from django.db.models.deletion import ( CASCADE, + DB_CASCADE, + DB_SET_DEFAULT, + DB_SET_NULL, DO_NOTHING, PROTECT, RESTRICT, @@ -75,6 +78,9 @@ __all__ += [ "ObjectDoesNotExist", "signals", "CASCADE", + "DB_CASCADE", + "DB_SET_DEFAULT", + "DB_SET_NULL", "DO_NOTHING", "PROTECT", "RESTRICT", diff --git a/django/db/models/base.py b/django/db/models/base.py index b92a198660..b58e7e3e52 100644 --- a/django/db/models/base.py +++ b/django/db/models/base.py @@ -30,7 +30,7 @@ from django.db import ( ) from django.db.models import NOT_PROVIDED, ExpressionWrapper, IntegerField, Max, Value from django.db.models.constants import LOOKUP_SEP -from django.db.models.deletion import CASCADE, Collector +from django.db.models.deletion import CASCADE, DO_NOTHING, Collector, DatabaseOnDelete from django.db.models.expressions import DatabaseDefault from django.db.models.fetch_modes import FETCH_ONE from django.db.models.fields.composite import CompositePrimaryKey @@ -1770,6 +1770,7 @@ class Model(AltersData, metaclass=ModelBase): *cls._check_fields(**kwargs), *cls._check_m2m_through_same_relationship(), *cls._check_long_column_names(databases), + *cls._check_related_fields(), ] clash_errors = ( *cls._check_id_field(), @@ -2455,6 +2456,29 @@ class Model(AltersData, metaclass=ModelBase): return errors + @classmethod + def _check_related_fields(cls): + has_db_variant = False + has_python_variant = False + for rel in cls._meta.get_fields(): + if rel.related_model: + if not (on_delete := getattr(rel.remote_field, "on_delete", None)): + continue + if isinstance(on_delete, DatabaseOnDelete): + has_db_variant = True + elif on_delete != DO_NOTHING: + has_python_variant = True + if has_db_variant and has_python_variant: + return [ + checks.Error( + "The model cannot have related fields with both " + "database-level and Python-level on_delete variants.", + obj=cls, + id="models.E050", + ) + ] + return [] + @classmethod def _get_expr_references(cls, expr): if isinstance(expr, Q): diff --git a/django/db/models/deletion.py b/django/db/models/deletion.py index 8d3fa5c92c..c42c7e9861 100644 --- a/django/db/models/deletion.py +++ b/django/db/models/deletion.py @@ -81,6 +81,28 @@ def DO_NOTHING(collector, field, sub_objs, using): pass +class DatabaseOnDelete: + def __init__(self, operation, name, forced_collector=None): + self.operation = operation + self.forced_collector = forced_collector + self.__name__ = name + + __call__ = DO_NOTHING + + def on_delete_sql(self, schema_editor): + return schema_editor.connection.ops.fk_on_delete_sql(self.operation) + + def __str__(self): + return self.__name__ + + +DB_CASCADE = DatabaseOnDelete("CASCADE", "DB_CASCADE", CASCADE) +DB_SET_DEFAULT = DatabaseOnDelete("SET DEFAULT", "DB_SET_DEFAULT") +DB_SET_NULL = DatabaseOnDelete("SET NULL", "DB_SET_NULL") + +SKIP_COLLECTION = frozenset([DO_NOTHING, DB_CASCADE, DB_SET_DEFAULT, DB_SET_NULL]) + + def get_candidate_relations_to_delete(opts): # The candidate relations are the ones that come from N-1 and 1-1 # relations. N-N (i.e., many-to-many) relations aren't candidates for @@ -93,10 +115,12 @@ def get_candidate_relations_to_delete(opts): class Collector: - def __init__(self, using, origin=None): + def __init__(self, using, origin=None, force_collection=False): self.using = using # A Model or QuerySet object. self.origin = origin + # Force collecting objects for deletion on the Python-level. + self.force_collection = force_collection # Initially, {model: {instances}}, later values become lists. self.data = defaultdict(set) # {(field, value): [instances, …]} @@ -194,6 +218,8 @@ class Collector: skipping parent -> child -> parent chain preventing fast delete of the child. """ + if self.force_collection: + return False if from_field and from_field.remote_field.on_delete is not CASCADE: return False if hasattr(objs, "_meta"): @@ -215,7 +241,7 @@ class Collector: and # Foreign keys pointing to this model. all( - related.field.remote_field.on_delete is DO_NOTHING + related.field.remote_field.on_delete in SKIP_COLLECTION for related in get_candidate_relations_to_delete(opts) ) and ( @@ -316,8 +342,13 @@ class Collector: continue field = related.field on_delete = field.remote_field.on_delete - if on_delete == DO_NOTHING: - continue + if on_delete in SKIP_COLLECTION: + if self.force_collection and ( + forced_on_delete := getattr(on_delete, "forced_collector", None) + ): + on_delete = forced_on_delete + else: + continue related_model = related.related_model if self.can_fast_delete(related_model, from_field=field): model_fast_deletes[related_model].append(field) diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index f12ae97968..3e2258e064 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -155,8 +155,6 @@ class Field(RegisterLookupMixin): "error_messages", "help_text", "limit_choices_to", - # Database-level options are not supported, see #21961. - "on_delete", "related_name", "related_query_name", "validators", diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index a71ae2f401..0293c78909 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -6,11 +6,19 @@ from django import forms from django.apps import apps from django.conf import SettingsReference, settings from django.core import checks, exceptions -from django.db import connection, router +from django.db import connection, connections, router from django.db.backends import utils -from django.db.models import Q +from django.db.models import NOT_PROVIDED, Q from django.db.models.constants import LOOKUP_SEP -from django.db.models.deletion import CASCADE, SET_DEFAULT, SET_NULL +from django.db.models.deletion import ( + CASCADE, + DB_SET_DEFAULT, + DB_SET_NULL, + DO_NOTHING, + SET_DEFAULT, + SET_NULL, + DatabaseOnDelete, +) from django.db.models.query_utils import PathInfo from django.db.models.utils import make_model_tuple from django.utils.functional import cached_property @@ -1041,18 +1049,21 @@ class ForeignKey(ForeignObject): return cls def check(self, **kwargs): + databases = kwargs.get("databases") or [] return [ *super().check(**kwargs), - *self._check_on_delete(), + *self._check_on_delete(databases), *self._check_unique(), ] - def _check_on_delete(self): + def _check_on_delete(self, databases): on_delete = getattr(self.remote_field, "on_delete", None) - if on_delete == SET_NULL and not self.null: - return [ + errors = [] + if on_delete in [DB_SET_NULL, SET_NULL] and not self.null: + errors.append( checks.Error( - "Field specifies on_delete=SET_NULL, but cannot be null.", + f"Field specifies on_delete={on_delete.__name__}, but cannot be " + "null.", hint=( "Set null=True argument on the field, or change the on_delete " "rule." @@ -1060,18 +1071,80 @@ class ForeignKey(ForeignObject): obj=self, id="fields.E320", ) - ] + ) elif on_delete == SET_DEFAULT and not self.has_default(): - return [ + errors.append( checks.Error( "Field specifies on_delete=SET_DEFAULT, but has no default value.", hint="Set a default value, or change the on_delete rule.", obj=self, id="fields.E321", ) - ] - else: - return [] + ) + elif on_delete == DB_SET_DEFAULT: + if self.db_default is NOT_PROVIDED: + errors.append( + checks.Error( + "Field specifies on_delete=DB_SET_DEFAULT, but has " + "no db_default value.", + hint="Set a db_default value, or change the on_delete rule.", + obj=self, + id="fields.E322", + ) + ) + for db in databases: + if not router.allow_migrate_model(db, self.model): + continue + connection = connections[db] + if not ( + "supports_on_delete_db_default" + in self.model._meta.required_db_features + or connection.features.supports_on_delete_db_default + ): + errors.append( + checks.Error( + f"{connection.display_name} does not support a " + "DB_SET_DEFAULT.", + hint="Change the on_delete rule to SET_DEFAULT.", + obj=self, + id="fields.E324", + ), + ) + elif not isinstance(self.remote_field.model, str) and on_delete != DO_NOTHING: + # Database and Python variants cannot be mixed in a chain of + # model references. + is_db_on_delete = isinstance(on_delete, DatabaseOnDelete) + ref_model_related_fields = ( + ref_model_field.remote_field + for ref_model_field in self.remote_field.model._meta.get_fields() + if ref_model_field.related_model + and hasattr(ref_model_field.remote_field, "on_delete") + ) + + for ref_remote_field in ref_model_related_fields: + if ( + ref_remote_field.on_delete is not None + and ref_remote_field.on_delete != DO_NOTHING + and isinstance(ref_remote_field.on_delete, DatabaseOnDelete) + is not is_db_on_delete + ): + on_delete_type = "database" if is_db_on_delete else "Python" + ref_on_delete_type = "Python" if is_db_on_delete else "database" + errors.append( + checks.Error( + f"Field specifies {on_delete_type}-level on_delete " + "variant, but referenced model uses " + f"{ref_on_delete_type}-level variant.", + hint=( + "Use either database or Python on_delete variants " + "uniformly in the references chain." + ), + obj=self, + id="fields.E323", + ) + ) + break + return errors def _check_unique(self, **kwargs): return ( diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index 7735eed478..c297938f45 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -299,9 +299,15 @@ Related fields referenced by a ``ForeignKey``. * **fields.E312**: The ``to_field`` ```` doesn't exist on the related model ``.``. -* **fields.E320**: Field specifies ``on_delete=SET_NULL``, but cannot be null. -* **fields.E321**: The field specifies ``on_delete=SET_DEFAULT``, but has no - default value. +* **fields.E320**: Field specifies ``on_delete=``, but cannot + be null. +* **fields.E321**: Field specifies ``on_delete=SET_DEFAULT``, but has no + ``default`` value. +* **fields.E322**: Field specifies ``on_delete=DB_SET_DEFAULT``, but has no + ``db_default`` value. +* **fields.E323**: Field specifies database/Python-level on_delete variant, but + referenced model uses python/database-level variant. +* **fields.E324**: ```` does not support ``DB_SET_DEFAULT``. * **fields.E330**: ``ManyToManyField``\s cannot be unique. * **fields.E331**: Field specifies a many-to-many relation through model ````, which has not been installed. @@ -446,6 +452,8 @@ Models * **models.E049**: ``constraints/indexes/unique_together`` refers to a ``ForeignObject`` ```` with multiple ``from_fields``, which is not supported for that option. +* **models.E050**: The model cannot have related fields with both + database-level and Python-level ``on_delete`` variants. Management Commands ------------------- @@ -921,6 +929,8 @@ The following checks are performed when a model contains a * **contenttypes.E004**: ```` is not a ``ForeignKey`` to ``contenttypes.ContentType``. * **contenttypes.E005**: Model names must be at most 100 characters. +* **contenttypes.E006**: ```` cannot use the database-level + ``on_delete`` variant. ``postgres`` ------------ diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 5988d5dc06..0b62143cc0 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1699,11 +1699,11 @@ relation works. .. attribute:: ForeignKey.on_delete - When an object referenced by a :class:`ForeignKey` is deleted, Django will - emulate the behavior of the SQL constraint specified by the - :attr:`on_delete` argument. For example, if you have a nullable - :class:`ForeignKey` and you want it to be set null when the referenced - object is deleted:: + When an object referenced by a :class:`ForeignKey` is deleted, the + referring objects need updating. The :attr:`on_delete` argument specifies + how this is done, and whether Django or your database makes the updates. + For example, if you have a nullable :class:`ForeignKey` and you want Django + to set it to ``None`` when the referenced object is deleted:: user = models.ForeignKey( User, @@ -1712,8 +1712,21 @@ relation works. null=True, ) - ``on_delete`` doesn't create an SQL constraint in the database. Support for - database-level cascade options :ticket:`may be implemented later <21961>`. + The possible values for :attr:`~ForeignKey.on_delete` are listed below. + Import them from :mod:`django.db.models`. The ``DB_*`` variants use the + database to prevent deletions or update referring objects, whilst the other + values make Django perform the relevant actions. + + The database variants are more efficient because they avoid fetching + related objects, but ``pre_delete`` and ``post_delete`` signals won't be + sent when ``DB_CASCADE`` is used. + + The database variants cannot be mixed with Python variants (other than + :attr:`DO_NOTHING`) in the same model and in models related to each other. + +.. versionchanged:: 6.1 + + Support for ``DB_*`` variants of the ``on_delete`` attribute was added. The possible values for :attr:`~ForeignKey.on_delete` are found in :mod:`django.db.models`: @@ -1729,6 +1742,13 @@ The possible values for :attr:`~ForeignKey.on_delete` are found in :data:`~django.db.models.signals.post_delete` signals are sent for all deleted objects. +* .. attribute:: DB_CASCADE + + .. versionadded:: 6.1 + + Cascade deletes. Database-level version of :attr:`CASCADE`: the database + deletes referred-to rows and the one containing the ``ForeignKey``. + * .. attribute:: PROTECT Prevent deletion of the referenced object by raising @@ -1782,11 +1802,30 @@ The possible values for :attr:`~ForeignKey.on_delete` are found in Set the :class:`ForeignKey` null; this is only possible if :attr:`~Field.null` is ``True``. +* .. attribute:: DB_SET_NULL + + .. versionadded:: 6.1 + + Set the :class:`ForeignKey` value to ``NULL``. This is only possible if + :attr:`~Field.null` is ``True``. Database-level version of + :attr:`SET_NULL`. + * .. attribute:: SET_DEFAULT Set the :class:`ForeignKey` to its default value; a default for the :class:`ForeignKey` must be set. +* .. attribute:: DB_SET_DEFAULT + + .. versionadded:: 6.1 + + Set the :class:`ForeignKey` value to its :attr:`Field.db_default` value, + which must be set. If a row in the referenced table is deleted, the foreign + key values in the referencing table will be updated to their + :attr:`Field.db_default` values. + + ``DB_SET_DEFAULT`` is not supported on MySQL and MariaDB. + * .. function:: SET() Set the :class:`ForeignKey` to the value passed to diff --git a/docs/ref/models/instances.txt b/docs/ref/models/instances.txt index 2ce8dc4a36..a8be767aaf 100644 --- a/docs/ref/models/instances.txt +++ b/docs/ref/models/instances.txt @@ -695,7 +695,11 @@ Issues an SQL ``DELETE`` for the object. This only deletes the object in the database; the Python instance will still exist and will still have data in its fields, except for the primary key set to ``None``. This method returns the number of objects deleted and a dictionary with the number of deletions per -object type. +object type. The return value will count instances from related models if +Django is emulating cascade behavior via Python :attr:`~ForeignKey.on_delete` +variants. Otherwise, for database variants such as +:attr:`~django.db.models.DB_CASCADE`, the return value will report only +instances of the :class:`.QuerySet`'s model. For more details, including how to delete objects in bulk, see :ref:`topics-db-queries-delete`. @@ -707,6 +711,10 @@ Sometimes with :ref:`multi-table inheritance ` you may want to delete only a child model's data. Specifying ``keep_parents=True`` will keep the parent model's data. +.. versionchanged:: 6.1 + + Support for the ``DB_*`` variants of ``on_delete`` attribute was added. + Pickling objects ================ diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 3840a2f97e..164bc9ce54 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -3036,7 +3036,11 @@ unique field in the order that is specified without conflicts. For example:: Performs an SQL delete query on all rows in the :class:`.QuerySet` and returns the number of objects deleted and a dictionary with the number of -deletions per object type. +deletions per object type. The return value will count instances from related +models if Django is emulating cascade behavior via Python +:attr:`~django.db.models.ForeignKey.on_delete` variants. Otherwise, for +database variants such as :attr:`~django.db.models.DB_CASCADE`, the return +value will report only instances of the :class:`.QuerySet`'s model. The ``delete()`` is applied instantly. You cannot call ``delete()`` on a :class:`.QuerySet` that has had a slice taken or can otherwise no longer be @@ -3073,13 +3077,20 @@ The ``delete()`` method does a bulk delete and does not call any ``delete()`` methods on your models. It does, however, emit the :data:`~django.db.models.signals.pre_delete` and :data:`~django.db.models.signals.post_delete` signals for all deleted objects -(including cascaded deletions). +(including cascaded deletions). Signals won't be sent when ``DB_CASCADE`` is +used. Also, ``delete()`` doesn't return information about objects deleted from +database variants (``DB_*``) of the +:attr:`~django.db.models.ForeignKey.on_delete` argument, e.g. ``DB_CASCADE``. -Django needs to fetch objects into memory to send signals and handle cascades. -However, if there are no cascades and no signals, then Django may take a -fast-path and delete objects without fetching into memory. For large -deletes this can result in significantly reduced memory usage. The amount of -executed queries can be reduced, too. +Django won’t need to fetch objects into memory when deleting them in the +following cases: + +#. If related fields use ``DB_*`` options. +#. If there are no cascades and no delete signal receivers. + +In these cases, Django may take a fast path and delete objects without fetching +them, which can result in significantly reduced memory usage and fewer executed +queries. ForeignKeys which are set to :attr:`~django.db.models.ForeignKey.on_delete` ``DO_NOTHING`` do not prevent taking the fast-path in deletion. @@ -3087,6 +3098,10 @@ ForeignKeys which are set to :attr:`~django.db.models.ForeignKey.on_delete` Note that the queries generated in object deletion is an implementation detail subject to change. +.. versionchanged:: 6.1 + + Support for the ``DB_*`` variants of ``on_delete`` attribute was added. + ``as_manager()`` ~~~~~~~~~~~~~~~~ diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 80470dbcd6..d199423176 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -71,6 +71,21 @@ queries: See :doc:`fetch modes ` for more details. +Database-level delete options for ``ForeignKey.on_delete`` +---------------------------------------------------------- + +:attr:`.ForeignKey.on_delete` now supports database-level delete options: + +* :attr:`~django.db.models.DB_CASCADE` +* :attr:`~django.db.models.DB_SET_NULL` +* :attr:`~django.db.models.DB_SET_DEFAULT` + +These options handle deletion logic entirely within the database, using the SQL +``ON DELETE`` clause. They are thus more efficient than the existing +Python-level options, as Django does not need to load objects before deleting +them. As a consequence, the :attr:`~django.db.models.DB_CASCADE` option does +not trigger the ``pre_delete`` or ``post_delete`` signals. + Minor features -------------- diff --git a/tests/admin_utils/models.py b/tests/admin_utils/models.py index 243f314b03..e5d2b67887 100644 --- a/tests/admin_utils/models.py +++ b/tests/admin_utils/models.py @@ -40,7 +40,7 @@ class ArticleProxy(Article): proxy = True -class Count(models.Model): +class Cascade(models.Model): num = models.PositiveSmallIntegerField() parent = models.ForeignKey("self", models.CASCADE, null=True) @@ -48,6 +48,14 @@ class Count(models.Model): return str(self.num) +class DBCascade(models.Model): + num = models.PositiveSmallIntegerField() + parent = models.ForeignKey("self", models.DB_CASCADE, null=True) + + def __str__(self): + return str(self.num) + + class Event(models.Model): date = models.DateTimeField(auto_now_add=True) diff --git a/tests/admin_utils/tests.py b/tests/admin_utils/tests.py index c90836c6d8..ce32535c52 100644 --- a/tests/admin_utils/tests.py +++ b/tests/admin_utils/tests.py @@ -26,7 +26,17 @@ from django.test.utils import isolate_apps from django.utils.formats import localize from django.utils.safestring import mark_safe -from .models import Article, Car, Count, Event, EventGuide, Location, Site, Vehicle +from .models import ( + Article, + Car, + Cascade, + DBCascade, + Event, + EventGuide, + Location, + Site, + Vehicle, +) class NestedObjectsTests(TestCase): @@ -34,10 +44,12 @@ class NestedObjectsTests(TestCase): Tests for ``NestedObject`` utility collection. """ + cascade_model = Cascade + @classmethod def setUpTestData(cls): cls.n = NestedObjects(using=DEFAULT_DB_ALIAS) - cls.objs = [Count.objects.create(num=i) for i in range(5)] + cls.objs = [cls.cascade_model.objects.create(num=i) for i in range(5)] def _check(self, target): self.assertEqual(self.n.nested(lambda obj: obj.num), target) @@ -103,6 +115,15 @@ class NestedObjectsTests(TestCase): n.collect([Vehicle.objects.first()]) +class DBNestedObjectsTests(NestedObjectsTests): + """ + Exercise NestedObjectsTests but with a model that makes use of DB_CASCADE + instead of CASCADE to ensure proper collection of objects takes place. + """ + + cascade_model = DBCascade + + class UtilsTests(SimpleTestCase): empty_value = "-empty-" diff --git a/tests/contenttypes_tests/test_checks.py b/tests/contenttypes_tests/test_checks.py index c33920f6b7..5c88b71777 100644 --- a/tests/contenttypes_tests/test_checks.py +++ b/tests/contenttypes_tests/test_checks.py @@ -80,6 +80,27 @@ class GenericForeignKeyTests(SimpleTestCase): ], ) + def test_content_type_db_on_delete(self): + class Model(models.Model): + content_type = models.ForeignKey(ContentType, models.DB_CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + + field = Model._meta.get_field("content_object") + + self.assertEqual( + field.check(), + [ + checks.Error( + "'Model.content_type' cannot use the database-level on_delete " + "variant.", + hint="Change the on_delete rule to the non-database variant.", + obj=field, + id="contenttypes.E006", + ) + ], + ) + def test_missing_object_id_field(self): class TaggedItem(models.Model): content_type = models.ForeignKey(ContentType, models.CASCADE) diff --git a/tests/delete/models.py b/tests/delete/models.py index 7f123b3396..bd9caf42a7 100644 --- a/tests/delete/models.py +++ b/tests/delete/models.py @@ -41,6 +41,46 @@ class RChildChild(RChild): pass +class RelatedDbOptionGrandParent(models.Model): + pass + + +class RelatedDbOptionParent(models.Model): + p = models.ForeignKey(RelatedDbOptionGrandParent, models.DB_CASCADE, null=True) + + +class RelatedDbOption(models.Model): + name = models.CharField(max_length=30) + db_setnull = models.ForeignKey( + RelatedDbOptionParent, + models.DB_SET_NULL, + null=True, + related_name="db_setnull_set", + ) + db_cascade = models.ForeignKey( + RelatedDbOptionParent, models.DB_CASCADE, related_name="db_cascade_set" + ) + + +class SetDefaultDbModel(models.Model): + db_setdefault = models.ForeignKey( + RelatedDbOptionParent, + models.DB_SET_DEFAULT, + db_default=models.Value(1), + related_name="db_setdefault_set", + ) + db_setdefault_none = models.ForeignKey( + RelatedDbOptionParent, + models.DB_SET_DEFAULT, + db_default=None, + null=True, + related_name="db_setnull_nullable_set", + ) + + class Meta: + required_db_features = {"supports_on_delete_db_default"} + + class A(models.Model): name = models.CharField(max_length=30) @@ -119,6 +159,15 @@ def create_a(name): return a +def create_related_db_option(name): + a = RelatedDbOption(name=name) + for name in ["db_setnull", "db_cascade"]: + r = RelatedDbOptionParent.objects.create() + setattr(a, name, r) + a.save() + return a + + class M(models.Model): m2m = models.ManyToManyField(R, related_name="m_set") m2m_through = models.ManyToManyField(R, through="MR", related_name="m_through_set") diff --git a/tests/delete/tests.py b/tests/delete/tests.py index 59140b5c62..8d525d1e5f 100644 --- a/tests/delete/tests.py +++ b/tests/delete/tests.py @@ -34,11 +34,16 @@ from .models import ( RChild, RChildChild, Referrer, + RelatedDbOption, + RelatedDbOptionGrandParent, + RelatedDbOptionParent, RProxy, S, + SetDefaultDbModel, T, User, create_a, + create_related_db_option, get_default_r, ) @@ -76,18 +81,48 @@ class OnDeleteTests(TestCase): a = A.objects.get(pk=a.pk) self.assertIsNone(a.setnull) + def test_db_setnull(self): + a = create_related_db_option("db_setnull") + a.db_setnull.delete() + a = RelatedDbOption.objects.get(pk=a.pk) + self.assertIsNone(a.db_setnull) + def test_setdefault(self): a = create_a("setdefault") a.setdefault.delete() a = A.objects.get(pk=a.pk) self.assertEqual(self.DEFAULT, a.setdefault.pk) + @skipUnlessDBFeature("supports_on_delete_db_default") + def test_db_setdefault(self): + # Object cannot be created on the module initialization, use hardcoded + # PKs instead. + r = RelatedDbOptionParent.objects.create(pk=2) + default_r = RelatedDbOptionParent.objects.create(pk=1) + set_default_db_obj = SetDefaultDbModel.objects.create(db_setdefault=r) + set_default_db_obj.db_setdefault.delete() + set_default_db_obj = SetDefaultDbModel.objects.get(pk=set_default_db_obj.pk) + self.assertEqual(set_default_db_obj.db_setdefault, default_r) + def test_setdefault_none(self): a = create_a("setdefault_none") a.setdefault_none.delete() a = A.objects.get(pk=a.pk) self.assertIsNone(a.setdefault_none) + @skipUnlessDBFeature("supports_on_delete_db_default") + def test_db_setdefault_none(self): + # Object cannot be created on the module initialization, use hardcoded + # PKs instead. + r = RelatedDbOptionParent.objects.create(pk=2) + default_r = RelatedDbOptionParent.objects.create(pk=1) + set_default_db_obj = SetDefaultDbModel.objects.create( + db_setdefault_none=r, db_setdefault=default_r + ) + set_default_db_obj.db_setdefault_none.delete() + set_default_db_obj = SetDefaultDbModel.objects.get(pk=set_default_db_obj.pk) + self.assertIsNone(set_default_db_obj.db_setdefault_none) + def test_cascade(self): a = create_a("cascade") a.cascade.delete() @@ -359,6 +394,22 @@ class DeletionTests(TestCase): self.assertNumQueries(5, s.delete) self.assertFalse(S.objects.exists()) + def test_db_cascade(self): + related_db_op = RelatedDbOptionParent.objects.create( + p=RelatedDbOptionGrandParent.objects.create() + ) + RelatedDbOption.objects.bulk_create( + [ + RelatedDbOption(db_cascade=related_db_op) + for _ in range(2 * GET_ITERATOR_CHUNK_SIZE) + ] + ) + with self.assertNumQueries(1): + results = related_db_op.delete() + self.assertEqual(results, (1, {"delete.RelatedDbOptionParent": 1})) + self.assertFalse(RelatedDbOption.objects.exists()) + self.assertFalse(RelatedDbOptionParent.objects.exists()) + def test_instance_update(self): deleted = [] related_setnull_sets = [] diff --git a/tests/invalid_models_tests/test_models.py b/tests/invalid_models_tests/test_models.py index 2a39e250bd..fe3c812615 100644 --- a/tests/invalid_models_tests/test_models.py +++ b/tests/invalid_models_tests/test_models.py @@ -3062,3 +3062,64 @@ class ConstraintsTests(TestCase): ), ], ) + + +@isolate_apps("invalid_models_tests") +class RelatedFieldTests(SimpleTestCase): + def test_on_delete_python_db_variants(self): + class Artist(models.Model): + pass + + class Album(models.Model): + artist = models.ForeignKey(Artist, models.CASCADE) + + class Song(models.Model): + album = models.ForeignKey(Album, models.RESTRICT) + artist = models.ForeignKey(Artist, models.DB_CASCADE) + + self.assertEqual( + Song.check(databases=self.databases), + [ + Error( + "The model cannot have related fields with both database-level and " + "Python-level on_delete variants.", + obj=Song, + id="models.E050", + ), + ], + ) + + def test_on_delete_python_db_variants_auto_created(self): + class SharedModel(models.Model): + pass + + class Parent(models.Model): + pass + + class Child(SharedModel): + parent = models.ForeignKey(Parent, on_delete=models.DB_CASCADE) + + self.assertEqual( + Child.check(databases=self.databases), + [ + Error( + "The model cannot have related fields with both database-level and " + "Python-level on_delete variants.", + obj=Child, + id="models.E050", + ), + ], + ) + + def test_on_delete_db_do_nothing(self): + class Artist(models.Model): + pass + + class Album(models.Model): + artist = models.ForeignKey(Artist, models.CASCADE) + + class Song(models.Model): + album = models.ForeignKey(Album, models.DO_NOTHING) + artist = models.ForeignKey(Artist, models.DB_CASCADE) + + self.assertEqual(Song.check(databases=self.databases), []) diff --git a/tests/invalid_models_tests/test_relative_fields.py b/tests/invalid_models_tests/test_relative_fields.py index ed6d39f7c6..e73f22ab41 100644 --- a/tests/invalid_models_tests/test_relative_fields.py +++ b/tests/invalid_models_tests/test_relative_fields.py @@ -3,7 +3,8 @@ from unittest import mock from django.core.checks import Error from django.core.checks import Warning as DjangoWarning from django.db import connection, models -from django.test.testcases import SimpleTestCase +from django.test import skipUnlessDBFeature +from django.test.testcases import SimpleTestCase, TestCase from django.test.utils import isolate_apps, modify_settings, override_settings @@ -751,6 +752,29 @@ class RelativeFieldTests(SimpleTestCase): ], ) + def test_on_delete_db_set_null_on_non_nullable_field(self): + class Person(models.Model): + pass + + class Model(models.Model): + foreign_key = models.ForeignKey("Person", models.DB_SET_NULL) + + field = Model._meta.get_field("foreign_key") + self.assertEqual( + field.check(), + [ + Error( + "Field specifies on_delete=DB_SET_NULL, but cannot be null.", + hint=( + "Set null=True argument on the field, or change the on_delete " + "rule." + ), + obj=field, + id="fields.E320", + ), + ], + ) + def test_on_delete_set_default_without_default_value(self): class Person(models.Model): pass @@ -2259,3 +2283,175 @@ class M2mThroughFieldsTests(SimpleTestCase): ), ], ) + + +@isolate_apps("invalid_models_tests") +class DatabaseLevelOnDeleteTests(TestCase): + + def test_db_set_default_support(self): + class Parent(models.Model): + pass + + class Child(models.Model): + parent = models.ForeignKey( + Parent, models.DB_SET_DEFAULT, db_default=models.Value(1) + ) + + field = Child._meta.get_field("parent") + expected = ( + [] + if connection.features.supports_on_delete_db_default + else [ + Error( + f"{connection.display_name} does not support a DB_SET_DEFAULT.", + hint="Change the on_delete rule to SET_DEFAULT.", + obj=field, + id="fields.E324", + ) + ] + ) + self.assertEqual(field.check(databases=self.databases), expected) + + def test_db_set_default_required_db_features(self): + class Parent(models.Model): + pass + + class Child(models.Model): + parent = models.ForeignKey( + Parent, models.DB_SET_DEFAULT, db_default=models.Value(1) + ) + + class Meta: + required_db_features = {"supports_on_delete_db_default"} + + field = Child._meta.get_field("parent") + self.assertEqual(field.check(databases=self.databases), []) + + @skipUnlessDBFeature("supports_on_delete_db_default") + def test_db_set_default_no_db_default(self): + class Parent(models.Model): + pass + + class Child(models.Model): + parent = models.ForeignKey(Parent, models.DB_SET_DEFAULT) + + field = Child._meta.get_field("parent") + self.assertEqual( + field.check(databases=self.databases), + [ + Error( + "Field specifies on_delete=DB_SET_DEFAULT, but has no db_default " + "value.", + hint="Set a db_default value, or change the on_delete rule.", + obj=field, + id="fields.E322", + ) + ], + ) + + def test_python_db_chain(self): + class GrandParent(models.Model): + pass + + class Parent(models.Model): + grand_parent = models.ForeignKey(GrandParent, models.DB_CASCADE) + + class Child(models.Model): + parent = models.ForeignKey(Parent, models.RESTRICT) + + field = Child._meta.get_field("parent") + self.assertEqual( + field.check(databases=self.databases), + [ + Error( + "Field specifies Python-level on_delete variant, but referenced " + "model uses database-level variant.", + hint=( + "Use either database or Python on_delete variants uniformly in " + "the references chain." + ), + obj=field, + id="fields.E323", + ) + ], + ) + + def test_db_python_chain(self): + class GrandParent(models.Model): + pass + + class Parent(models.Model): + grand_parent = models.ForeignKey(GrandParent, models.CASCADE) + + class Child(models.Model): + parent = models.ForeignKey(Parent, models.DB_SET_NULL, null=True) + + field = Child._meta.get_field("parent") + self.assertEqual( + field.check(databases=self.databases), + [ + Error( + "Field specifies database-level on_delete variant, but referenced " + "model uses Python-level variant.", + hint=( + "Use either database or Python on_delete variants uniformly in " + "the references chain." + ), + obj=field, + id="fields.E323", + ) + ], + ) + + def test_db_python_chain_auto_created(self): + class GrandParent(models.Model): + pass + + class Parent(GrandParent): + pass + + class Child(models.Model): + parent = models.ForeignKey(Parent, on_delete=models.DB_CASCADE) + + field = Child._meta.get_field("parent") + self.assertEqual( + field.check(databases=self.databases), + [ + Error( + "Field specifies database-level on_delete variant, but referenced " + "model uses Python-level variant.", + hint=( + "Use either database or Python on_delete variants uniformly in " + "the references chain." + ), + obj=field, + id="fields.E323", + ) + ], + ) + + def test_db_do_nothing_chain(self): + class GrandParent(models.Model): + pass + + class Parent(models.Model): + grand_parent = models.ForeignKey(GrandParent, models.DO_NOTHING) + + class Child(models.Model): + parent = models.ForeignKey(Parent, models.DB_SET_NULL, null=True) + + field = Child._meta.get_field("parent") + self.assertEqual(field.check(databases=self.databases), []) + + def test_do_nothing_db_chain(self): + class GrandParent(models.Model): + pass + + class Parent(models.Model): + grand_parent = models.ForeignKey(GrandParent, models.DB_SET_NULL, null=True) + + class Child(models.Model): + parent = models.ForeignKey(Parent, models.DO_NOTHING) + + field = Child._meta.get_field("parent") + self.assertEqual(field.check(databases=self.databases), []) diff --git a/tests/migrations/test_writer.py b/tests/migrations/test_writer.py index 29f472b85b..b7b199d8f1 100644 --- a/tests/migrations/test_writer.py +++ b/tests/migrations/test_writer.py @@ -971,6 +971,23 @@ class WriterTests(SimpleTestCase): ("('models.Model', {'from django.db import models'})", set()), ) + def test_database_on_delete_serializer_value(self): + db_level_on_delete_options = [ + models.DB_CASCADE, + models.DB_SET_DEFAULT, + models.DB_SET_NULL, + ] + for option in db_level_on_delete_options: + self.assertSerializedEqual(option) + self.assertSerializedResultEqual( + MigrationWriter.serialize(option), + ( + f"('django.db.models.deletion.{option.__name__}', " + "{'import django.db.models.deletion'})", + set(), + ), + ) + def test_simple_migration(self): """ Tests serializing a simple migration. diff --git a/tests/schema/tests.py b/tests/schema/tests.py index 88d4ebbc8b..ab8b07e9d3 100644 --- a/tests/schema/tests.py +++ b/tests/schema/tests.py @@ -18,6 +18,8 @@ from django.db import ( from django.db.backends.utils import truncate_name from django.db.models import ( CASCADE, + DB_CASCADE, + DB_SET_NULL, PROTECT, AutoField, BigAutoField, @@ -410,6 +412,40 @@ class SchemaTests(TransactionTestCase): ] ) + @skipUnlessDBFeature("can_create_inline_fk") + def test_inline_fk_db_on_delete(self): + with connection.schema_editor() as editor: + editor.create_model(Author) + editor.create_model(Book) + editor.create_model(Note) + self.assertForeignKeyNotExists(Note, "book_id", "schema_book") + # Add a foreign key from model to the other. + with ( + CaptureQueriesContext(connection) as ctx, + connection.schema_editor() as editor, + ): + new_field = ForeignKey(Book, DB_CASCADE) + new_field.set_attributes_from_name("book") + editor.add_field(Note, new_field) + self.assertForeignKeyExists(Note, "book_id", "schema_book") + # Creating a FK field with a constraint uses a single statement without + # a deferred ALTER TABLE. + self.assertFalse( + [ + sql + for sql in (str(statement) for statement in editor.deferred_sql) + if sql.startswith("ALTER TABLE") and "ADD CONSTRAINT" in sql + ] + ) + # ON DELETE clause is used. + self.assertTrue( + any( + capture_query["sql"].startswith("ALTER TABLE") + and "ON DELETE" in capture_query["sql"] + for capture_query in ctx.captured_queries + ) + ) + @skipUnlessDBFeature("can_create_inline_fk") def test_add_inline_fk_update_data(self): with connection.schema_editor() as editor: @@ -566,6 +602,63 @@ class SchemaTests(TransactionTestCase): editor.alter_field(Author, new_field2, new_field, strict=True) self.assertForeignKeyNotExists(Author, "tag_id", "schema_tag") + @skipUnlessDBFeature("supports_foreign_keys", "can_introspect_foreign_keys") + def test_fk_alter_on_delete(self): + with connection.schema_editor() as editor: + editor.create_model(Author) + editor.create_model(Book) + self.assertForeignKeyExists(Book, "author_id", "schema_author") + # Change CASCADE to DB_CASCADE. + old_field = Book._meta.get_field("author") + new_field = ForeignKey(Author, DB_CASCADE) + new_field.set_attributes_from_name("author") + with ( + connection.schema_editor() as editor, + CaptureQueriesContext(connection) as ctx, + ): + editor.alter_field(Book, old_field, new_field) + self.assertForeignKeyExists(Book, "author_id", "schema_author") + self.assertIs( + any("ON DELETE" in query["sql"] for query in ctx.captured_queries), True + ) + # Change DB_CASCADE to CASCADE. + old_field = new_field + new_field = ForeignKey(Author, CASCADE) + new_field.set_attributes_from_name("author") + with ( + connection.schema_editor() as editor, + CaptureQueriesContext(connection) as ctx, + ): + editor.alter_field(Book, old_field, new_field) + self.assertForeignKeyExists(Book, "author_id", "schema_author") + self.assertIs( + any("ON DELETE" in query["sql"] for query in ctx.captured_queries), False + ) + + @isolate_apps("schema") + @skipUnlessDBFeature("supports_foreign_keys", "can_introspect_foreign_keys") + def test_create_model_db_on_delete(self): + class Parent(Model): + class Meta: + app_label = "schema" + + class Child(Model): + parent_fk = ForeignKey(Parent, DB_SET_NULL, null=True) + + class Meta: + app_label = "schema" + + with connection.schema_editor() as editor: + editor.create_model(Parent) + with CaptureQueriesContext(connection) as ctx: + with connection.schema_editor() as editor: + editor.create_model(Child) + + self.assertForeignKeyNotExists(Child, "parent_id", "schema_parent") + self.assertIs( + any("ON DELETE" in query["sql"] for query in ctx.captured_queries), True + ) + @isolate_apps("schema") def test_no_db_constraint_added_during_primary_key_change(self): """ @@ -4598,6 +4691,7 @@ class SchemaTests(TransactionTestCase): "to_table": editor.quote_name(table), "to_column": editor.quote_name(model._meta.auto_field.column), "deferrable": connection.ops.deferrable_sql(), + "on_delete_db": "", } ) self.assertIn( @@ -4784,7 +4878,7 @@ class SchemaTests(TransactionTestCase): error_messages={"invalid": "error message"}, help_text="help text", limit_choices_to={"limit": "choice"}, - on_delete=PROTECT, + on_delete=CASCADE, related_name="related_name", related_query_name="related_query_name", validators=[lambda x: x], From d506e4a52847ed4fb80754175d76b6b83ff90929 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Sat, 18 Oct 2025 21:04:11 +0200 Subject: [PATCH 084/116] Fixed #36671 -- Dropped support for SQLite < 3.37. --- django/db/backends/sqlite3/features.py | 23 ++++++--------------- django/db/backends/sqlite3/introspection.py | 3 +-- django/db/backends/sqlite3/schema.py | 3 +-- docs/ref/contrib/gis/install/index.txt | 2 +- docs/ref/databases.txt | 2 +- docs/ref/models/querysets.txt | 4 ++-- docs/releases/6.1.txt | 2 ++ tests/backends/sqlite/tests.py | 4 ++-- 8 files changed, 16 insertions(+), 27 deletions(-) diff --git a/django/db/backends/sqlite3/features.py b/django/db/backends/sqlite3/features.py index 143ee1e98b..4fa6ab831b 100644 --- a/django/db/backends/sqlite3/features.py +++ b/django/db/backends/sqlite3/features.py @@ -10,7 +10,7 @@ from .base import Database class DatabaseFeatures(BaseDatabaseFeatures): - minimum_database_version = (3, 31) + minimum_database_version = (3, 37) test_db_allows_multiple_connections = False supports_unspecified_pk = True supports_timezones = False @@ -26,8 +26,6 @@ class DatabaseFeatures(BaseDatabaseFeatures): time_cast_precision = 3 can_release_savepoints = True has_case_insensitive_like = True - # Is "ALTER TABLE ... DROP COLUMN" supported? - can_alter_table_drop_column = Database.sqlite_version_info >= (3, 35, 5) supports_parentheses_in_compound = False can_defer_constraint_checks = True supports_over_clause = True @@ -57,6 +55,9 @@ class DatabaseFeatures(BaseDatabaseFeatures): insert_test_table_with_defaults = 'INSERT INTO {} ("null") VALUES (1)' supports_default_keyword_in_insert = False supports_unlimited_charfield = True + can_return_columns_from_insert = True + can_return_rows_from_bulk_insert = True + can_return_rows_from_update = True @cached_property def django_test_skips(self): @@ -146,8 +147,8 @@ class DatabaseFeatures(BaseDatabaseFeatures): """ SQLite has a variable limit per query. The limit can be changed using the SQLITE_MAX_VARIABLE_NUMBER compile-time option (which defaults to - 999 in versions < 3.32.0 or 32766 in newer versions) or lowered per - connection at run-time with setlimit(SQLITE_LIMIT_VARIABLE_NUMBER, N). + 32766) or lowered per connection at run-time with + setlimit(SQLITE_LIMIT_VARIABLE_NUMBER, N). """ return self.connection.connection.getlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER) @@ -163,15 +164,3 @@ class DatabaseFeatures(BaseDatabaseFeatures): can_introspect_json_field = property(operator.attrgetter("supports_json_field")) has_json_object_function = property(operator.attrgetter("supports_json_field")) - - @cached_property - def can_return_columns_from_insert(self): - return Database.sqlite_version_info >= (3, 35) - - can_return_rows_from_bulk_insert = property( - operator.attrgetter("can_return_columns_from_insert") - ) - - can_return_rows_from_update = property( - operator.attrgetter("can_return_columns_from_insert") - ) diff --git a/django/db/backends/sqlite3/introspection.py b/django/db/backends/sqlite3/introspection.py index 0bbd6f4c59..1404c71e1e 100644 --- a/django/db/backends/sqlite3/introspection.py +++ b/django/db/backends/sqlite3/introspection.py @@ -342,8 +342,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): "PRAGMA index_list(%s)" % self.connection.ops.quote_name(table_name) ) for row in cursor.fetchall(): - # SQLite 3.8.9+ has 5 columns, however older versions only give 3 - # columns. Discard last 2 columns if there. + # Discard last 2 columns. number, index, unique = row[:3] cursor.execute( "SELECT sql FROM sqlite_master WHERE type='index' AND name=%s", diff --git a/django/db/backends/sqlite3/schema.py b/django/db/backends/sqlite3/schema.py index 223a70947b..ee6163c253 100644 --- a/django/db/backends/sqlite3/schema.py +++ b/django/db/backends/sqlite3/schema.py @@ -339,10 +339,9 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): self.delete_model(field.remote_field.through) # For explicit "through" M2M fields, do nothing elif ( - self.connection.features.can_alter_table_drop_column # Primary keys, unique fields, indexed fields, and foreign keys are # not supported in ALTER TABLE DROP COLUMN. - and not field.primary_key + not field.primary_key and not field.unique and not field.db_index and not (field.remote_field and field.db_constraint) diff --git a/docs/ref/contrib/gis/install/index.txt b/docs/ref/contrib/gis/install/index.txt index e08c78b147..f127478151 100644 --- a/docs/ref/contrib/gis/install/index.txt +++ b/docs/ref/contrib/gis/install/index.txt @@ -60,7 +60,7 @@ Database Library Requirements Supported Versions Notes PostgreSQL GEOS, GDAL, PROJ, PostGIS 15+ Requires PostGIS. MySQL GEOS, GDAL 8.0.11+ :ref:`Limited functionality `. Oracle GEOS, GDAL 19+ XE not supported. -SQLite GEOS, GDAL, PROJ, SpatiaLite 3.31.0+ Requires SpatiaLite 4.3+ +SQLite GEOS, GDAL, PROJ, SpatiaLite 3.37.0+ Requires SpatiaLite 4.3+ ================== ============================== ================== ========================================= See also `this comparison matrix`__ on the OSGeo Wiki for diff --git a/docs/ref/databases.txt b/docs/ref/databases.txt index cbd0e2feea..cd415e1c00 100644 --- a/docs/ref/databases.txt +++ b/docs/ref/databases.txt @@ -814,7 +814,7 @@ appropriate typecasting. SQLite notes ============ -Django supports SQLite 3.31.0 and later. +Django supports SQLite 3.37.0 and later. SQLite_ provides an excellent development alternative for applications that are predominantly read-only or require a smaller installation footprint. As diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 164bc9ce54..d9badb690d 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -2439,8 +2439,8 @@ This has a number of caveats though: * If the model's primary key is an :class:`~django.db.models.AutoField` or has a :attr:`~django.db.models.Field.db_default` value, and ``ignore_conflicts`` is ``False``, the primary key attribute can only be retrieved on certain - databases (currently PostgreSQL, MariaDB, and SQLite 3.35+). On other - databases, it will not be set. + databases (currently PostgreSQL, MariaDB, and SQLite). On other databases, it + will not be set. * It does not work with many-to-many relationships. * It casts ``objs`` to a list, which fully evaluates ``objs`` if it's a generator. The cast allows inspecting all objects so that any objects with a diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index d199423176..1430cb4f17 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -325,6 +325,8 @@ Miscellaneous * :class:`~django.contrib.contenttypes.fields.GenericForeignKey` now uses a separate descriptor class: the private ``GenericForeignKeyDescriptor``. +* The minimum supported version of SQLite is increased from 3.31.0 to 3.37.0. + .. _deprecated-features-6.1: Features deprecated in 6.1 diff --git a/tests/backends/sqlite/tests.py b/tests/backends/sqlite/tests.py index fafc0b182f..37d95c0cb5 100644 --- a/tests/backends/sqlite/tests.py +++ b/tests/backends/sqlite/tests.py @@ -109,9 +109,9 @@ class Tests(TestCase): connections["default"].close() self.assertTrue(os.path.isfile(os.path.join(tmp, "test.db"))) - @mock.patch.object(connection, "get_database_version", return_value=(3, 30)) + @mock.patch.object(connection, "get_database_version", return_value=(3, 36)) def test_check_database_version_supported(self, mocked_get_database_version): - msg = "SQLite 3.31 or later is required (found 3.30)." + msg = "SQLite 3.37 or later is required (found 3.36)." with self.assertRaisesMessage(NotSupportedError, msg): connection.check_database_version_supported() self.assertTrue(mocked_get_database_version.called) From ca3e0484ef31d13053af6a9d50667813e22fc282 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Sun, 19 Oct 2025 20:13:16 +0200 Subject: [PATCH 085/116] Refs #36005 -- Bumped minimum supported versions of docutils to 0.22. --- docs/internals/contributing/writing-code/unit-tests.txt | 2 +- docs/ref/contrib/admin/admindocs.txt | 2 +- docs/releases/6.0.txt | 1 + tests/admin_docs/test_utils.py | 2 +- tests/requirements/py3.txt | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt index acbf68a7de..22938c1cea 100644 --- a/docs/internals/contributing/writing-code/unit-tests.txt +++ b/docs/internals/contributing/writing-code/unit-tests.txt @@ -317,7 +317,7 @@ dependencies: * :pypi:`asgiref` 3.9.1+ (required) * :pypi:`bcrypt` 4.1.1+ * :pypi:`colorama` 0.4.6+ -* :pypi:`docutils` 0.19+ +* :pypi:`docutils` 0.22+ * :pypi:`geoip2` 4.8.0+ * :pypi:`Jinja2` 2.11+ * :pypi:`numpy` 1.26.0+ diff --git a/docs/ref/contrib/admin/admindocs.txt b/docs/ref/contrib/admin/admindocs.txt index 1355c83356..27e8b6251a 100644 --- a/docs/ref/contrib/admin/admindocs.txt +++ b/docs/ref/contrib/admin/admindocs.txt @@ -23,7 +23,7 @@ the following: your ``urlpatterns``. Make sure it's included *before* the ``'admin/'`` entry, so that requests to ``/admin/doc/`` don't get handled by the latter entry. -* Install the :pypi:`docutils` 0.19+ package. +* Install the :pypi:`docutils` 0.22+ package. * **Optional:** Using the admindocs bookmarklets requires ``django.contrib.admindocs.middleware.XViewMiddleware`` to be installed. diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index 0c9da42cc6..d90be35b1b 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -377,6 +377,7 @@ of each library are the first to add or confirm compatibility with Python 3.12: * ``aiosmtpd`` 1.4.5 * ``argon2-cffi`` 23.1.0 * ``bcrypt`` 4.1.1 +* ``docutils`` 0.22 * ``geoip2`` 4.8.0 * ``Pillow`` 10.1.0 * ``mysqlclient`` 2.2.1 diff --git a/tests/admin_docs/test_utils.py b/tests/admin_docs/test_utils.py index 8152857263..2369fe5106 100644 --- a/tests/admin_docs/test_utils.py +++ b/tests/admin_docs/test_utils.py @@ -133,5 +133,5 @@ class TestUtils(AdminDocsSimpleTestCase): ) source = "reST, `interpreted text`, default role." markup = "

    reST, interpreted text, default role.

    \n" - parts = docutils.core.publish_parts(source=source, writer_name="html4css1") + parts = docutils.core.publish_parts(source=source, writer="html4css1") self.assertEqual(parts["fragment"], markup) diff --git a/tests/requirements/py3.txt b/tests/requirements/py3.txt index 76a017aab4..4fbf425e48 100644 --- a/tests/requirements/py3.txt +++ b/tests/requirements/py3.txt @@ -3,7 +3,7 @@ asgiref >= 3.9.1 argon2-cffi >= 23.1.0 bcrypt >= 4.1.1 black >= 25.9.0 -docutils >= 0.19 +docutils >= 0.22 geoip2 >= 4.8.0 jinja2 >= 2.11.0 numpy >= 1.26.0 From 344ae16e1e21ab7c0b594d755519738f7f16eaf1 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Mon, 20 Oct 2025 16:03:39 +0200 Subject: [PATCH 086/116] Fixed RelatedGeoModelTest.test_related_union_aggregate() test on Oracle and GEOS 3.12+. --- tests/gis_tests/relatedapp/tests.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/gis_tests/relatedapp/tests.py b/tests/gis_tests/relatedapp/tests.py index 34dc2bba15..8baf65be27 100644 --- a/tests/gis_tests/relatedapp/tests.py +++ b/tests/gis_tests/relatedapp/tests.py @@ -100,10 +100,15 @@ class RelatedGeoModelTest(TestCase): self.assertEqual(type(u3), MultiPoint) # Ordering of points in the result of the union is not defined and - # implementation-dependent (DB backend, GEOS version) - self.assertEqual({p.ewkt for p in ref_u1}, {p.ewkt for p in u1}) - self.assertEqual({p.ewkt for p in ref_u2}, {p.ewkt for p in u2}) - self.assertEqual({p.ewkt for p in ref_u1}, {p.ewkt for p in u3}) + # implementation-dependent (DB backend, GEOS version). + tests = [ + (u1, ref_u1), + (u2, ref_u2), + (u3, ref_u1), + ] + for union, ref in tests: + for point, ref_point in zip(sorted(union), sorted(ref), strict=True): + self.assertIs(point.equals_exact(ref_point, tolerance=6), True) def test05_select_related_fk_to_subclass(self): """ From 5625bd590766e5ca8c2c76ba2307b98f7450ff83 Mon Sep 17 00:00:00 2001 From: Emmanuel Ferdman Date: Mon, 20 Oct 2025 20:52:02 +0300 Subject: [PATCH 087/116] Removed duplicate display_raw key in expected data in GeometryWidgetTests. Signed-off-by: Emmanuel Ferdman --- tests/gis_tests/test_geoforms.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/gis_tests/test_geoforms.py b/tests/gis_tests/test_geoforms.py index 753505fc68..4b229d8897 100644 --- a/tests/gis_tests/test_geoforms.py +++ b/tests/gis_tests/test_geoforms.py @@ -507,7 +507,6 @@ class GeometryWidgetTests(SimpleTestCase): "map_srid": 4326, "geom_name": "Geometry", "geom_type": "GEOMETRY", - "display_raw": False, }, "name": "name", "template_name": "", From 9bb83925d6c231e964f8b54efbc982fb1333da27 Mon Sep 17 00:00:00 2001 From: YashRaj1506 Date: Thu, 26 Jun 2025 03:31:00 +0530 Subject: [PATCH 088/116] Fixed #36470 -- Prevented log injection in runserver when handling NOT FOUND. Migrated `WSGIRequestHandler.log_message()` to use a more robust `log_message()` helper, which was based of `log_response()` via factoring out the common bits. Refs CVE-2025-48432. Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> --- django/core/servers/basehttp.py | 43 +++++++++------------ django/utils/log.py | 67 +++++++++++++++++++++++---------- tests/servers/test_basehttp.py | 15 ++++++++ 3 files changed, 80 insertions(+), 45 deletions(-) diff --git a/django/core/servers/basehttp.py b/django/core/servers/basehttp.py index 41719034fb..d62b88d286 100644 --- a/django/core/servers/basehttp.py +++ b/django/core/servers/basehttp.py @@ -18,6 +18,7 @@ from django.core.exceptions import ImproperlyConfigured from django.core.handlers.wsgi import LimitedStream from django.core.wsgi import get_wsgi_application from django.db import connections +from django.utils.log import log_message from django.utils.module_loading import import_string __all__ = ("WSGIServer", "WSGIRequestHandler") @@ -182,35 +183,27 @@ class WSGIRequestHandler(simple_server.WSGIRequestHandler): return self.client_address[0] def log_message(self, format, *args): - extra = { - "request": self.request, - "server_time": self.log_date_time_string(), - } - if args[1][0] == "4": + if args[1][0] == "4" and args[0].startswith("\x16\x03"): # 0x16 = Handshake, 0x03 = SSL 3.0 or TLS 1.x - if args[0].startswith("\x16\x03"): - extra["status_code"] = 500 - logger.error( - "You're accessing the development server over HTTPS, but " - "it only supports HTTP.", - extra=extra, - ) - return - - if args[1].isdigit() and len(args[1]) == 3: + format = ( + "You're accessing the development server over HTTPS, but it only " + "supports HTTP." + ) + status_code = 500 + args = () + elif args[1].isdigit() and len(args[1]) == 3: status_code = int(args[1]) - extra["status_code"] = status_code - - if status_code >= 500: - level = logger.error - elif status_code >= 400: - level = logger.warning - else: - level = logger.info else: - level = logger.info + status_code = None - level(format, *args, extra=extra) + log_message( + logger, + format, + *args, + request=self.request, + status_code=status_code, + server_time=self.log_date_time_string(), + ) def get_environ(self): # Strip all headers with underscores in the name before constructing diff --git a/django/utils/log.py b/django/utils/log.py index 67a40270f0..d4e96a9816 100644 --- a/django/utils/log.py +++ b/django/utils/log.py @@ -214,6 +214,46 @@ class ServerFormatter(logging.Formatter): return self._fmt.find("{server_time}") >= 0 +def log_message( + logger, + message, + *args, + level=None, + status_code=None, + request=None, + exception=None, + **extra, +): + """Log `message` using `logger` based on `status_code` and logger `level`. + + Pass `request`, `status_code` (if defined) and any provided `extra` as such + to the logging method, + + Arguments from `args` will be escaped to avoid potential log injections. + + """ + extra = {"request": request, **extra} + if status_code is not None: + extra["status_code"] = status_code + if level is None: + if status_code >= 500: + level = "error" + elif status_code >= 400: + level = "warning" + + escaped_args = tuple( + a.encode("unicode_escape").decode("ascii") if isinstance(a, str) else a + for a in args + ) + + getattr(logger, level or "info")( + message, + *escaped_args, + extra=extra, + exc_info=exception, + ) + + def log_response( message, *args, @@ -237,26 +277,13 @@ def log_response( if getattr(response, "_has_been_logged", False): return - if level is None: - if response.status_code >= 500: - level = "error" - elif response.status_code >= 400: - level = "warning" - else: - level = "info" - - escaped_args = tuple( - a.encode("unicode_escape").decode("ascii") if isinstance(a, str) else a - for a in args - ) - - getattr(logger, level)( + log_message( + logger, message, - *escaped_args, - extra={ - "status_code": response.status_code, - "request": request, - }, - exc_info=exception, + *args, + level=level, + status_code=response.status_code, + request=request, + exception=exception, ) response._has_been_logged = True diff --git a/tests/servers/test_basehttp.py b/tests/servers/test_basehttp.py index cc4701114a..9190fc8a20 100644 --- a/tests/servers/test_basehttp.py +++ b/tests/servers/test_basehttp.py @@ -50,6 +50,21 @@ class WSGIRequestHandlerTestCase(SimpleTestCase): cm.records[0].levelname, wrong_level.upper() ) + def test_log_message_escapes_control_sequences(self): + request = WSGIRequest(self.request_factory.get("/").environ) + request.makefile = lambda *args, **kwargs: BytesIO() + handler = WSGIRequestHandler(request, "192.168.0.2", None) + + malicious_path = "\x1b[31mALERT\x1b[0m" + + with self.assertLogs("django.server", "WARNING") as cm: + handler.log_message("GET %s %s", malicious_path, "404") + + log = cm.output[0] + + self.assertNotIn("\x1b[31m", log) + self.assertIn("\\x1b[31mALERT\\x1b[0m", log) + def test_https(self): request = WSGIRequest(self.request_factory.get("/").environ) request.makefile = lambda *args, **kwargs: BytesIO() From a0323a0c44135c28134672e6e633e5f4a7a68d5d Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sat, 11 Oct 2025 00:10:35 +0100 Subject: [PATCH 089/116] Fixed #36656 -- Avoided truncating async streaming responses in GZipMiddleware. --- django/middleware/gzip.py | 18 +++++------------- django/utils/text.py | 16 ++++++++++++++++ tests/middleware/tests.py | 5 +++-- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/django/middleware/gzip.py b/django/middleware/gzip.py index 7ccd00ac19..eb151d7ad5 100644 --- a/django/middleware/gzip.py +++ b/django/middleware/gzip.py @@ -1,7 +1,7 @@ from django.utils.cache import patch_vary_headers from django.utils.deprecation import MiddlewareMixin from django.utils.regex_helper import _lazy_re_compile -from django.utils.text import compress_sequence, compress_string +from django.utils.text import acompress_sequence, compress_sequence, compress_string re_accepts_gzip = _lazy_re_compile(r"\bgzip\b") @@ -32,18 +32,10 @@ class GZipMiddleware(MiddlewareMixin): if response.streaming: if response.is_async: - # pull to lexical scope to capture fixed reference in case - # streaming_content is set again later. - original_iterator = response.streaming_content - - async def gzip_wrapper(): - async for chunk in original_iterator: - yield compress_string( - chunk, - max_random_bytes=self.max_random_bytes, - ) - - response.streaming_content = gzip_wrapper() + response.streaming_content = acompress_sequence( + response.streaming_content, + max_random_bytes=self.max_random_bytes, + ) else: response.streaming_content = compress_sequence( response.streaming_content, diff --git a/django/utils/text.py b/django/utils/text.py index 26edde99e3..bad1da6729 100644 --- a/django/utils/text.py +++ b/django/utils/text.py @@ -393,6 +393,22 @@ def compress_sequence(sequence, *, max_random_bytes=None): yield buf.read() +async def acompress_sequence(sequence, *, max_random_bytes=None): + buf = StreamingBuffer() + filename = _get_random_filename(max_random_bytes) if max_random_bytes else None + with GzipFile( + filename=filename, mode="wb", compresslevel=6, fileobj=buf, mtime=0 + ) as zfile: + # Output headers... + yield buf.read() + async for item in sequence: + zfile.write(item) + data = buf.read() + if data: + yield data + yield buf.read() + + # Expression to match some_token and some_token="with spaces" (and similarly # for single-quoted strings). smart_split_re = _lazy_re_compile( diff --git a/tests/middleware/tests.py b/tests/middleware/tests.py index c4aac0552b..a61c4b147f 100644 --- a/tests/middleware/tests.py +++ b/tests/middleware/tests.py @@ -2,6 +2,7 @@ import gzip import random import re import struct +import zlib from io import BytesIO from unittest import mock from urllib.parse import quote @@ -880,8 +881,8 @@ class GZipMiddlewareTest(SimpleTestCase): @staticmethod def decompress(gzipped_string): - with gzip.GzipFile(mode="rb", fileobj=BytesIO(gzipped_string)) as f: - return f.read() + # Use zlib to ensure gzipped_string contains exactly one gzip stream. + return zlib.decompress(gzipped_string, zlib.MAX_WBITS | 16) @staticmethod def get_mtime(gzipped_string): From 548209e620b3ca34396a360453f07c8dbb8aa6c7 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Tue, 21 Oct 2025 21:11:44 +0200 Subject: [PATCH 090/116] Made RemoteTestResultTest.test_pickle_errors_detection() compatible with tblib 3.2+. tblib 3.2+ makes exception subclasses with __init__() and the default __reduce__() picklable. This broke the test for RemoteTestResult._confirm_picklable(), which expects a specific exception to fail unpickling. https://github.com/ionelmc/python-tblib/blob/master/CHANGELOG.rst#320-2025-10-21 This fix defines ExceptionThatFailsUnpickling.__reduce__() in a way that pickle.dumps(obj) succeeds, but pickle.loads(pickle.dumps(obj)) raises TypeError. Refs #27301. This preserves the intent of the regression test from 52188a5ca6bafea0a66f17baacb315d61c7b99cd without skipping it. --- tests/test_runner/test_parallel.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_runner/test_parallel.py b/tests/test_runner/test_parallel.py index fa129da768..32cc971d30 100644 --- a/tests/test_runner/test_parallel.py +++ b/tests/test_runner/test_parallel.py @@ -32,6 +32,12 @@ class ExceptionThatFailsUnpickling(Exception): def __init__(self, arg): super().__init__() + def __reduce__(self): + # tblib 3.2+ makes exception subclasses picklable by default. + # Return (cls, ()) so the constructor fails on unpickle, preserving + # the needed behavior for test_pickle_errors_detection. + return (self.__class__, ()) + class ParallelTestRunnerTest(SimpleTestCase): """ @@ -170,6 +176,8 @@ class RemoteTestResultTest(SimpleTestCase): result = RemoteTestResult() result._confirm_picklable(picklable_error) + # The exception can be pickled but not unpickled. + pickle.dumps(not_unpicklable_error) msg = "__init__() missing 1 required positional argument" with self.assertRaisesMessage(TypeError, msg): result._confirm_picklable(not_unpicklable_error) From b6c9246d0a3ef5f9a40b15cc289b495351eae109 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Tue, 21 Oct 2025 13:10:52 -0400 Subject: [PATCH 091/116] Fixed #36677 -- Fixed scheduling of system checks in ParallelTestSuite workers. Running system checks in workers must happen after database aliases are set up. Regression in 606fc352799e372928fa2c978ab99f0fb6d6017c. --- django/test/runner.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/django/test/runner.py b/django/test/runner.py index 25089a6db1..41c9dbd10c 100644 --- a/django/test/runner.py +++ b/django/test/runner.py @@ -463,9 +463,6 @@ def _init_worker( process_setup(*process_setup_args) django.setup() setup_test_environment(debug=debug_mode) - call_command( - "check", stdout=io.StringIO(), stderr=io.StringIO(), databases=used_aliases - ) db_aliases = used_aliases if used_aliases is not None else connections for alias in db_aliases: @@ -477,6 +474,11 @@ def _init_worker( connection._test_serialized_contents = value connection.creation.setup_worker_connection(_worker_id) + if is_spawn_or_forkserver: + call_command( + "check", stdout=io.StringIO(), stderr=io.StringIO(), databases=used_aliases + ) + def _run_subsuite(args): """ From 185b049e9e72a5ff4b07e33605e10eb4f52ca74c Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Wed, 22 Oct 2025 10:04:38 +0200 Subject: [PATCH 092/116] Refs #36499 -- Made TestUtilsHtml.test_strip_tags() assume behavior change in X.Y.0 version for Python 3.14+. This also removes unsupported versions of Python from the test dict. --- tests/utils_tests/test_html.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py index 7167383aef..bf00d14496 100644 --- a/tests/utils_tests/test_html.py +++ b/tests/utils_tests/test_html.py @@ -116,21 +116,20 @@ class TestUtilsHtml(SimpleTestCase): self.check_output(linebreaks, lazystr(value), output) def test_strip_tags(self): - # Python fixed a quadratic-time issue in HTMLParser in 3.13.6, 3.12.12, - # 3.11.14, 3.10.19, and 3.9.24. The fix slightly changes HTMLParser's - # output, so tests for particularly malformed input must handle both - # old and new results. The check below is temporary until all supported - # Python versions and CI workers include the fix. See: + # Python fixed a quadratic-time issue in HTMLParser in 3.13.6, 3.12.12. + # The fix slightly changes HTMLParser's output, so tests for + # particularly malformed input must handle both old and new results. + # The check below is temporary until all supported Python versions and + # CI workers include the fix. See: # https://github.com/python/cpython/commit/6eb6c5db min_fixed = { - (3, 14): (3, 14), (3, 13): (3, 13, 6), (3, 12): (3, 12, 12), - (3, 11): (3, 11, 14), - (3, 10): (3, 10, 19), - (3, 9): (3, 9, 24), } - htmlparser_fixed = sys.version_info >= min_fixed[sys.version_info[:2]] + major_version = sys.version_info[:2] + htmlparser_fixed = sys.version_info >= min_fixed.get( + major_version, major_version + ) items = ( ( "

    See: 'é is an apostrophe followed by e acute

    ", From 5e2bbebed9f36bb9d15f168444e7982287761877 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Wed, 22 Oct 2025 15:36:10 +0200 Subject: [PATCH 093/116] Refs #36664 -- Added Python 3.15 to daily builds. --- .github/workflows/schedule_tests.yml | 1 + tests/requirements/py3.txt | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/schedule_tests.yml b/.github/workflows/schedule_tests.yml index 402659b338..5490c35a4c 100644 --- a/.github/workflows/schedule_tests.yml +++ b/.github/workflows/schedule_tests.yml @@ -19,6 +19,7 @@ jobs: - '3.12' - '3.13' - '3.14' + - '3.15-dev' name: Windows, SQLite, Python ${{ matrix.python-version }} continue-on-error: true steps: diff --git a/tests/requirements/py3.txt b/tests/requirements/py3.txt index 4fbf425e48..e07f97ef69 100644 --- a/tests/requirements/py3.txt +++ b/tests/requirements/py3.txt @@ -6,8 +6,8 @@ black >= 25.9.0 docutils >= 0.22 geoip2 >= 4.8.0 jinja2 >= 2.11.0 -numpy >= 1.26.0 -Pillow >= 10.1.0 +numpy >= 1.26.0; sys.platform != 'win32' or python_version < '3.15' +Pillow >= 10.1.0; sys.platform != 'win32' or python_version < '3.15' # pylibmc/libmemcached can't be built on Windows. pylibmc; sys_platform != 'win32' pymemcache >= 3.4.0 From 42d6e20feba81fb1182c8111d0c18e492fbe4e87 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Wed, 22 Oct 2025 15:34:27 -0300 Subject: [PATCH 094/116] Made cosmetic edits to docs/releases/6.0.txt. --- docs/releases/6.0.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index d90be35b1b..88ea04a9fa 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -124,7 +124,7 @@ Backends are configured via the :setting:`TASKS` setting. The :ref:`two built-in backends ` included in this release are primarily intended for development and testing. -Django handles task creation and queuing but does not provide a worker +Django handles task creation and queuing, but does not provide a worker mechanism to run tasks. Execution must be managed by external infrastructure, such as a separate process or service. @@ -575,7 +575,7 @@ to remove usage of these features. * Support for calling ``format_html()`` without passing args or kwargs is removed. -* The default scheme for ``forms.URLField`` changed from ``"http"`` to +* The default scheme for ``forms.URLField`` has changed from ``"http"`` to ``"https"``. * The ``FORMS_URLFIELD_ASSUME_HTTPS`` transitional setting is removed. From 74239181252ca73bebb84789856f5d8937d421b4 Mon Sep 17 00:00:00 2001 From: Annabelle Wiegart <44520920+annalauraw@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:11:52 +0200 Subject: [PATCH 095/116] Fixed #35095 -- Clarified Swiss number formatting in docs/topics/i18n/formatting.txt. Co-authored-by: Ahmed Nassar --- AUTHORS | 1 + django/conf/locale/de_CH/formats.py | 8 +++----- django/conf/locale/fr_CH/formats.py | 4 ++++ docs/topics/i18n/formatting.txt | 17 +++++++++-------- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/AUTHORS b/AUTHORS index be55e379c2..7e287e75ed 100644 --- a/AUTHORS +++ b/AUTHORS @@ -97,6 +97,7 @@ answer newbie questions, and generally made Django that much better: Andy Dustman Andy Gayton andy@jadedplanet.net + Annabelle Wiegart Anssi Kääriäinen ant9000@netwise.it Anthony Briggs diff --git a/django/conf/locale/de_CH/formats.py b/django/conf/locale/de_CH/formats.py index f42dd48739..bf048462bd 100644 --- a/django/conf/locale/de_CH/formats.py +++ b/django/conf/locale/de_CH/formats.py @@ -25,11 +25,9 @@ DATETIME_INPUT_FORMATS = [ "%d.%m.%Y %H:%M", # '25.10.2006 14:30' ] -# these are the separators for non-monetary numbers. For monetary numbers, -# the DECIMAL_SEPARATOR is a . (decimal point) and the THOUSAND_SEPARATOR is a -# ' (single quote). -# For details, please refer to the documentation and the following link: -# https://www.bk.admin.ch/bk/de/home/dokumentation/sprachen/hilfsmittel-textredaktion/schreibweisungen.html +# Swiss number formatting can vary based on context (e.g. Fr. 23.50 vs 22,5 m). +# Django does not support context-specific formatting and uses generic +# separators. DECIMAL_SEPARATOR = "," THOUSAND_SEPARATOR = "\xa0" # non-breaking space NUMBER_GROUPING = 3 diff --git a/django/conf/locale/fr_CH/formats.py b/django/conf/locale/fr_CH/formats.py index 84f065713e..0a63166e80 100644 --- a/django/conf/locale/fr_CH/formats.py +++ b/django/conf/locale/fr_CH/formats.py @@ -27,6 +27,10 @@ DATETIME_INPUT_FORMATS = [ "%d/%m/%Y %H:%M:%S.%f", # '25/10/2006 14:30:59.000200' "%d/%m/%Y %H:%M", # '25/10/2006 14:30' ] + +# Swiss number formatting can vary based on context (e.g. Fr. 23.50 vs 22,5 m). +# Django does not support context-specific formatting and uses generic +# separators. DECIMAL_SEPARATOR = "," THOUSAND_SEPARATOR = "\xa0" # non-breaking space NUMBER_GROUPING = 3 diff --git a/docs/topics/i18n/formatting.txt b/docs/topics/i18n/formatting.txt index c670f02b25..ed941a20e0 100644 --- a/docs/topics/i18n/formatting.txt +++ b/docs/topics/i18n/formatting.txt @@ -189,12 +189,13 @@ Limitations of the provided locale formats Some locales use context-sensitive formats for numbers, which Django's localization system cannot handle automatically. -Switzerland (German) --------------------- +Switzerland (German, French) +---------------------------- -The Swiss number formatting depends on the type of number that is being -formatted. For monetary values, a comma is used as the thousand separator and -a decimal point for the decimal separator. For all other numbers, a comma is -used as decimal separator and a space as thousand separator. The locale format -provided by Django uses the generic separators, a comma for decimal and a space -for thousand separators. +The Swiss number formatting traditionally varies depending on context. For +example, monetary values may use a dot as decimal separator (``Fr. 23.50``), +while measurements often use a comma (``22,5 m``). Django’s localization system +does not support such context-specific variations automatically. + +The locale format provided by Django uses the generic separators, a comma for +decimal and a space for thousand separators. From 6fcbbe0b855d8701a4da1b65772ccf326f996b9e Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Fri, 24 Oct 2025 09:50:01 -0300 Subject: [PATCH 096/116] Fixed IntegrityError in bulk_create.tests.BulkCreateTransactionTests due to duplicate primary keys. Some tests in BulkCreateTransactionTests were inserting Country objects with hardcoded primary keys, which could conflict with existing rows (if the sequence value wasn't bumped by another test). Updated the tests to dynamically select an unused primary key instead. Thanks to Simon Charette for the exhaustive and enlightening review. --- tests/bulk_create/tests.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/bulk_create/tests.py b/tests/bulk_create/tests.py index 8ab918fff5..7b77ffed3d 100644 --- a/tests/bulk_create/tests.py +++ b/tests/bulk_create/tests.py @@ -889,19 +889,27 @@ class BulkCreateTests(TestCase): class BulkCreateTransactionTests(TransactionTestCase): available_apps = ["bulk_create"] + def get_unused_country_id(self): + # Find a serial ID that hasn't been used already and has enough of a + # buffer for the following `bulk_create` call without an explicit pk + # not to conflict. + return getattr(Country.objects.last(), "id", 10) + 100 + def test_no_unnecessary_transaction(self): + unused_id = self.get_unused_country_id() with self.assertNumQueries(1): Country.objects.bulk_create( - [Country(id=1, name="France", iso_two_letter="FR")] + [Country(id=unused_id, name="France", iso_two_letter="FR")] ) with self.assertNumQueries(1): Country.objects.bulk_create([Country(name="Canada", iso_two_letter="CA")]) def test_objs_with_and_without_pk(self): + unused_id = self.get_unused_country_id() with self.assertNumQueries(4): Country.objects.bulk_create( [ - Country(id=10, name="France", iso_two_letter="FR"), + Country(id=unused_id, name="France", iso_two_letter="FR"), Country(name="Canada", iso_two_letter="CA"), ] ) From 3ff32c50d143d8a498f9a5dfef1a31b16a7456fe Mon Sep 17 00:00:00 2001 From: Ken Nzioka Date: Wed, 22 Oct 2025 10:48:23 +0300 Subject: [PATCH 097/116] Fixed #36674 -- Fixed memory leak in select_related(). --- django/db/models/sql/compiler.py | 17 ++++++++--------- tests/select_related/tests.py | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/django/db/models/sql/compiler.py b/django/db/models/sql/compiler.py index 0e483dc4f6..14603d5773 100644 --- a/django/db/models/sql/compiler.py +++ b/django/db/models/sql/compiler.py @@ -228,6 +228,13 @@ class SQLCompiler: ] return expressions + @classmethod + def get_select_from_parent(cls, klass_info): + for ki in klass_info["related_klass_infos"]: + if ki["from_parent"]: + ki["select_fields"] = klass_info["select_fields"] + ki["select_fields"] + cls.get_select_from_parent(ki) + def get_select(self, with_col_aliases=False): """ Return three values: @@ -300,15 +307,7 @@ class SQLCompiler: related_klass_infos = self.get_related_selections(select, select_mask) klass_info["related_klass_infos"] = related_klass_infos - def get_select_from_parent(klass_info): - for ki in klass_info["related_klass_infos"]: - if ki["from_parent"]: - ki["select_fields"] = ( - klass_info["select_fields"] + ki["select_fields"] - ) - get_select_from_parent(ki) - - get_select_from_parent(klass_info) + self.get_select_from_parent(klass_info) ret = [] col_idx = 1 diff --git a/tests/select_related/tests.py b/tests/select_related/tests.py index 41ed350cf3..59d1270aa0 100644 --- a/tests/select_related/tests.py +++ b/tests/select_related/tests.py @@ -1,6 +1,9 @@ +import gc + from django.core.exceptions import FieldError from django.db.models import FETCH_PEERS from django.test import SimpleTestCase, TestCase +from django.test.utils import garbage_collect from .models import ( Bookmark, @@ -57,6 +60,17 @@ class SelectRelatedTests(TestCase): "Amanita muscaria" ) + def setup_gc_debug(self): + self.addCleanup(gc.set_debug, 0) + self.addCleanup(gc.enable) + gc.disable() + garbage_collect() + gc.set_debug(gc.DEBUG_SAVEALL) + + def assert_no_memory_leaks(self): + garbage_collect() + self.assertEqual(gc.garbage, []) + def test_access_fks_without_select_related(self): """ Normally, accessing FKs doesn't fill in related objects @@ -128,6 +142,11 @@ class SelectRelatedTests(TestCase): ) self.assertEqual(s.id + 10, s.a) + def test_select_related_memory_leak(self): + self.setup_gc_debug() + list(Species.objects.select_related("genus")) + self.assert_no_memory_leaks() + def test_certain_fields(self): """ The optional fields passed to select_related() control which related From 4744e9939b65d168c531e5e23d1ac8a4445ac7f9 Mon Sep 17 00:00:00 2001 From: Matthew Shirley Date: Thu, 23 Oct 2025 11:52:32 -0700 Subject: [PATCH 098/116] Fixed #36683 -- Added error message on QuerySet.update() following distinct(*fields). --- AUTHORS | 1 + django/db/models/query.py | 2 ++ tests/distinct_on_fields/tests.py | 6 ++++++ 3 files changed, 9 insertions(+) diff --git a/AUTHORS b/AUTHORS index 7e287e75ed..28169c3e17 100644 --- a/AUTHORS +++ b/AUTHORS @@ -707,6 +707,7 @@ answer newbie questions, and generally made Django that much better: Matt Dennenbaum Matthew Flanagan Matthew Schinckel + Matthew Shirley Matthew Somerville Matthew Tretter Matthew Wilkes diff --git a/django/db/models/query.py b/django/db/models/query.py index a2af672546..70177667a6 100644 --- a/django/db/models/query.py +++ b/django/db/models/query.py @@ -1338,6 +1338,8 @@ class QuerySet(AltersData): self._not_support_combined_queries("update") if self.query.is_sliced: raise TypeError("Cannot update a query once a slice has been taken.") + if self.query.distinct_fields: + raise TypeError("Cannot call update() after .distinct(*fields).") self._for_write = True query = self.query.chain(sql.UpdateQuery) query.add_update_values(kwargs) diff --git a/tests/distinct_on_fields/tests.py b/tests/distinct_on_fields/tests.py index 93b3f27aec..f03e05ac73 100644 --- a/tests/distinct_on_fields/tests.py +++ b/tests/distinct_on_fields/tests.py @@ -178,3 +178,9 @@ class DistinctOnTests(TestCase): .order_by("nAmEAlIaS") ) self.assertSequenceEqual(qs, [self.p1_o1, self.p2_o1, self.p3_o1]) + + def test_disallowed_update_distinct_on(self): + qs = Staff.objects.distinct("organisation").order_by("organisation") + msg = "Cannot call update() after .distinct(*fields)." + with self.assertRaisesMessage(TypeError, msg): + qs.update(name="p4") From c87daabbf32779f5421a846dd33a7dd46cc27d54 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Mon, 27 Oct 2025 15:05:23 +0100 Subject: [PATCH 099/116] Fixed #36624 -- Dropped support for MySQL < 8.4. --- .../gis/db/backends/mysql/operations.py | 2 - .../contrib/gis/db/backends/mysql/schema.py | 10 --- django/db/backends/mysql/base.py | 7 +- django/db/backends/mysql/features.py | 66 +------------------ django/db/backends/mysql/operations.py | 14 ++-- django/db/backends/mysql/schema.py | 18 +---- docs/ref/contrib/gis/install/index.txt | 2 +- docs/ref/databases.txt | 2 +- docs/ref/models/indexes.txt | 5 +- docs/ref/models/querysets.txt | 4 +- docs/releases/6.1.txt | 6 ++ tests/backends/mysql/tests.py | 4 +- tests/queries/test_explain.py | 4 +- 13 files changed, 26 insertions(+), 118 deletions(-) diff --git a/django/contrib/gis/db/backends/mysql/operations.py b/django/contrib/gis/db/backends/mysql/operations.py index f838a79da6..b82bd16abb 100644 --- a/django/contrib/gis/db/backends/mysql/operations.py +++ b/django/contrib/gis/db/backends/mysql/operations.py @@ -76,8 +76,6 @@ class MySQLOperations(BaseSpatialOperations, DatabaseOperations): if is_mariadb: if self.connection.mysql_version < (12, 0, 1): disallowed_aggregates.insert(0, models.Collect) - elif self.connection.mysql_version < (8, 0, 24): - disallowed_aggregates.insert(0, models.Collect) return tuple(disallowed_aggregates) function_names = { diff --git a/django/contrib/gis/db/backends/mysql/schema.py b/django/contrib/gis/db/backends/mysql/schema.py index e485c671e5..78e97bb1ca 100644 --- a/django/contrib/gis/db/backends/mysql/schema.py +++ b/django/contrib/gis/db/backends/mysql/schema.py @@ -10,16 +10,6 @@ logger = logging.getLogger("django.contrib.gis") class MySQLGISSchemaEditor(DatabaseSchemaEditor): sql_add_spatial_index = "CREATE SPATIAL INDEX %(index)s ON %(table)s(%(column)s)" - def skip_default(self, field): - # Geometry fields are stored as BLOB/TEXT, for which MySQL < 8.0.13 - # doesn't support defaults. - if ( - isinstance(field, GeometryField) - and not self._supports_limited_data_type_defaults - ): - return True - return super().skip_default(field) - def quote_value(self, value): if isinstance(value, self.connection.ops.Adapter): return super().quote_value(str(value)) diff --git a/django/db/backends/mysql/base.py b/django/db/backends/mysql/base.py index e83dc106f7..d4b98971fa 100644 --- a/django/db/backends/mysql/base.py +++ b/django/db/backends/mysql/base.py @@ -144,11 +144,8 @@ class DatabaseWrapper(BaseDatabaseWrapper): _data_types["UUIDField"] = "uuid" return _data_types - # For these data types: - # - MySQL < 8.0.13 doesn't accept default values and implicitly treats them - # as nullable - # - all versions of MySQL and MariaDB don't support full width database - # indexes + # For these data types MySQL and MariaDB don't support full width database + # indexes. _limited_data_types = ( "tinyblob", "blob", diff --git a/django/db/backends/mysql/features.py b/django/db/backends/mysql/features.py index 4be20b92ac..3315db6ae9 100644 --- a/django/db/backends/mysql/features.py +++ b/django/db/backends/mysql/features.py @@ -66,7 +66,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): if self.connection.mysql_is_mariadb: return (10, 6) else: - return (8, 0, 11) + return (8, 4) @cached_property def test_collations(self): @@ -104,24 +104,6 @@ class DatabaseFeatures(BaseDatabaseFeatures): "update.tests.AdvancedTests.test_update_ordered_by_m2m_annotation_desc", }, } - if not self.supports_explain_analyze: - skips.update( - { - "MariaDB and MySQL >= 8.0.18 specific.": { - "queries.test_explain.ExplainTests.test_mysql_analyze", - }, - } - ) - if self.connection.mysql_version < (8, 0, 31): - skips.update( - { - "Nesting of UNIONs at the right-hand side is not supported on " - "MySQL < 8.0.31": { - "queries.test_qs_combinators.QuerySetSetOperationTests." - "test_union_nested" - }, - } - ) if not self.connection.mysql_is_mariadb: skips.update( { @@ -186,44 +168,16 @@ class DatabaseFeatures(BaseDatabaseFeatures): def is_sql_auto_is_null_enabled(self): return self.connection.mysql_server_data["sql_auto_is_null"] - @cached_property - def supports_column_check_constraints(self): - if self.connection.mysql_is_mariadb: - return True - return self.connection.mysql_version >= (8, 0, 16) - - supports_table_check_constraints = property( - operator.attrgetter("supports_column_check_constraints") - ) - - @cached_property - def can_introspect_check_constraints(self): - if self.connection.mysql_is_mariadb: - return True - return self.connection.mysql_version >= (8, 0, 16) - @cached_property def has_select_for_update_of(self): return not self.connection.mysql_is_mariadb - @cached_property - def supports_explain_analyze(self): - return self.connection.mysql_is_mariadb or self.connection.mysql_version >= ( - 8, - 0, - 18, - ) - @cached_property def supported_explain_formats(self): # Alias MySQL's TRADITIONAL to TEXT for consistency with other # backends. formats = {"JSON", "TEXT", "TRADITIONAL"} - if not self.connection.mysql_is_mariadb and self.connection.mysql_version >= ( - 8, - 0, - 16, - ): + if not self.connection.mysql_is_mariadb: formats.add("TREE") return formats @@ -262,24 +216,8 @@ class DatabaseFeatures(BaseDatabaseFeatures): return ( not self.connection.mysql_is_mariadb and self._mysql_storage_engine != "MyISAM" - and self.connection.mysql_version >= (8, 0, 13) ) - @cached_property - def supports_select_intersection(self): - is_mariadb = self.connection.mysql_is_mariadb - return is_mariadb or self.connection.mysql_version >= (8, 0, 31) - - supports_select_difference = property( - operator.attrgetter("supports_select_intersection") - ) - - @cached_property - def supports_expression_defaults(self): - if self.connection.mysql_is_mariadb: - return True - return self.connection.mysql_version >= (8, 0, 13) - @cached_property def has_native_uuid_field(self): is_mariadb = self.connection.mysql_is_mariadb diff --git a/django/db/backends/mysql/operations.py b/django/db/backends/mysql/operations.py index 7dfcd57958..74ba72f316 100644 --- a/django/db/backends/mysql/operations.py +++ b/django/db/backends/mysql/operations.py @@ -349,7 +349,7 @@ class DatabaseOperations(BaseDatabaseOperations): format = "TREE" analyze = options.pop("analyze", False) prefix = super().explain_query_prefix(format, **options) - if analyze and self.connection.features.supports_explain_analyze: + if analyze: # MariaDB uses ANALYZE instead of EXPLAIN ANALYZE. prefix = ( "ANALYZE" if self.connection.mysql_is_mariadb else prefix + " ANALYZE" @@ -407,15 +407,11 @@ class DatabaseOperations(BaseDatabaseOperations): def on_conflict_suffix_sql(self, fields, on_conflict, update_fields, unique_fields): if on_conflict == OnConflict.UPDATE: conflict_suffix_sql = "ON DUPLICATE KEY UPDATE %(fields)s" - # The use of VALUES() is deprecated in MySQL 8.0.20+. Instead, use - # aliases for the new row and its columns available in MySQL - # 8.0.19+. + # The use of VALUES() is not supported in MySQL. Instead, use + # aliases for the new row and its columns. if not self.connection.mysql_is_mariadb: - if self.connection.mysql_version >= (8, 0, 19): - conflict_suffix_sql = f"AS new {conflict_suffix_sql}" - field_sql = "%(field)s = new.%(field)s" - else: - field_sql = "%(field)s = VALUES(%(field)s)" + conflict_suffix_sql = f"AS new {conflict_suffix_sql}" + field_sql = "%(field)s = new.%(field)s" # Use VALUE() on MariaDB. else: field_sql = "%(field)s = VALUE(%(field)s)" diff --git a/django/db/backends/mysql/schema.py b/django/db/backends/mysql/schema.py index ab388754ed..9eba216256 100644 --- a/django/db/backends/mysql/schema.py +++ b/django/db/backends/mysql/schema.py @@ -65,13 +65,10 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): default_is_empty = self.effective_default(field) in ("", b"") if default_is_empty and self._is_text_or_blob(field): return True - if not self._supports_limited_data_type_defaults: - return self._is_limited_data_type(field) return False def skip_default_on_alter(self, field): - default_is_empty = self.effective_default(field) in ("", b"") - if default_is_empty and self._is_text_or_blob(field): + if self.skip_default(field): return True if self._is_limited_data_type(field) and not self.connection.mysql_is_mariadb: # MySQL doesn't support defaults for BLOB and TEXT in the @@ -79,19 +76,8 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): return True return False - @property - def _supports_limited_data_type_defaults(self): - # MariaDB and MySQL >= 8.0.13 support defaults for BLOB and TEXT. - if self.connection.mysql_is_mariadb: - return True - return self.connection.mysql_version >= (8, 0, 13) - def _column_default_sql(self, field): - if ( - not self.connection.mysql_is_mariadb - and self._supports_limited_data_type_defaults - and self._is_limited_data_type(field) - ): + if not self.connection.mysql_is_mariadb and self._is_limited_data_type(field): # MySQL supports defaults for BLOB and TEXT columns only if the # default value is written as an expression i.e. in parentheses. return "(%s)" diff --git a/docs/ref/contrib/gis/install/index.txt b/docs/ref/contrib/gis/install/index.txt index f127478151..54c0491c65 100644 --- a/docs/ref/contrib/gis/install/index.txt +++ b/docs/ref/contrib/gis/install/index.txt @@ -58,7 +58,7 @@ supported versions, and any notes for each of the supported database backends: Database Library Requirements Supported Versions Notes ================== ============================== ================== ========================================= PostgreSQL GEOS, GDAL, PROJ, PostGIS 15+ Requires PostGIS. -MySQL GEOS, GDAL 8.0.11+ :ref:`Limited functionality `. +MySQL GEOS, GDAL 8.4+ :ref:`Limited functionality `. Oracle GEOS, GDAL 19+ XE not supported. SQLite GEOS, GDAL, PROJ, SpatiaLite 3.37.0+ Requires SpatiaLite 4.3+ ================== ============================== ================== ========================================= diff --git a/docs/ref/databases.txt b/docs/ref/databases.txt index cd415e1c00..9ea54297aa 100644 --- a/docs/ref/databases.txt +++ b/docs/ref/databases.txt @@ -434,7 +434,7 @@ MySQL notes Version support --------------- -Django supports MySQL 8.0.11 and higher. +Django supports MySQL 8.4 and higher. Django's ``inspectdb`` feature uses the ``information_schema`` database, which contains detailed data on all database schemas. diff --git a/docs/ref/models/indexes.txt b/docs/ref/models/indexes.txt index d2b2430643..6f8dd39fb7 100644 --- a/docs/ref/models/indexes.txt +++ b/docs/ref/models/indexes.txt @@ -63,10 +63,9 @@ and the ``weight`` rounded to the nearest integer. error. This means that functions such as :class:`Concat() ` aren't accepted. -.. admonition:: MySQL and MariaDB +.. admonition:: MariaDB - Functional indexes are ignored with MySQL < 8.0.13 and MariaDB as neither - supports them. + Functional indexes are unsupported and ignored with MariaDB. ``fields`` ---------- diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index d9badb690d..3e7024211e 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -3161,8 +3161,8 @@ Pass these flags as keyword arguments. For example, when using PostgreSQL: On some databases, flags may cause the query to be executed which could have adverse effects on your database. For example, the ``ANALYZE`` flag supported -by MariaDB, MySQL 8.0.18+, and PostgreSQL could result in changes to data if -there are triggers or if a function is called, even for a ``SELECT`` query. +by MariaDB, MySQL, and PostgreSQL could result in changes to data if there are +triggers or if a function is called, even for a ``SELECT`` query. .. _field-lookups: diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 1430cb4f17..670026077a 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -319,6 +319,12 @@ Dropped support for PostgreSQL 14 Upstream support for PostgreSQL 14 ends in November 2026. Django 6.1 supports PostgreSQL 15 and higher. +Dropped support for MySQL < 8.4 +------------------------------- + +Upstream support for MySQL 8.0 ends in April 2026, and MySQL 8.1-8.3 are +short-term innovation releases. Django 6.1 supports MySQL 8.4 and higher. + Miscellaneous ------------- diff --git a/tests/backends/mysql/tests.py b/tests/backends/mysql/tests.py index e718f9fae4..15228d254f 100644 --- a/tests/backends/mysql/tests.py +++ b/tests/backends/mysql/tests.py @@ -109,8 +109,8 @@ class Tests(TestCase): mocked_get_database_version.return_value = (10, 5) msg = "MariaDB 10.6 or later is required (found 10.5)." else: - mocked_get_database_version.return_value = (8, 0, 4) - msg = "MySQL 8.0.11 or later is required (found 8.0.4)." + mocked_get_database_version.return_value = (8, 0, 31) + msg = "MySQL 8.4 or later is required (found 8.0.31)." with self.assertRaisesMessage(NotSupportedError, msg): connection.check_database_version_supported() diff --git a/tests/queries/test_explain.py b/tests/queries/test_explain.py index 95ca913cfc..59bd0e8d08 100644 --- a/tests/queries/test_explain.py +++ b/tests/queries/test_explain.py @@ -159,9 +159,7 @@ class ExplainTests(TestCase): self.assertEqual(len(captured_queries), 1) self.assertIn("FORMAT=TRADITIONAL", captured_queries[0]["sql"]) - @unittest.skipUnless( - connection.vendor == "mysql", "MariaDB and MySQL >= 8.0.18 specific." - ) + @unittest.skipUnless(connection.vendor == "mysql", "MySQL specific") def test_mysql_analyze(self): qs = Tag.objects.filter(name="test") with CaptureQueriesContext(connection) as captured_queries: From 0ea01101c3a35568bc43e9707ac058b9874bd425 Mon Sep 17 00:00:00 2001 From: Kasyap Pentamaraju Date: Thu, 23 Oct 2025 10:17:55 +0530 Subject: [PATCH 100/116] Fixed #36681 -- Removed English pluralization bias from example in docs/topics/i18n/translation.txt. --- AUTHORS | 1 + docs/topics/i18n/translation.txt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 28169c3e17..5acbe27233 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1066,6 +1066,7 @@ answer newbie questions, and generally made Django that much better: Unai Zalakain Valentina Mukhamedzhanova valtron + Varun Kasyap Pentamaraju Vasiliy Stavenko Vasil Vangelovski Vibhu Agarwal diff --git a/docs/topics/i18n/translation.txt b/docs/topics/i18n/translation.txt index 89dd11e0f4..560aa476a1 100644 --- a/docs/topics/i18n/translation.txt +++ b/docs/topics/i18n/translation.txt @@ -715,7 +715,7 @@ An example: .. code-block:: html+django {% blocktranslate count counter=list|length %} - There is only one {{ name }} object. + There is {{ counter }} {{ name }} object. {% plural %} There are {{ counter }} {{ name }} objects. {% endblocktranslate %} From ea3a71c2d09f8281d8a50ed20e40e1fb13db5cd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ml=C3=A1dek?= Date: Mon, 26 May 2025 18:37:34 +0200 Subject: [PATCH 101/116] Fixed #26434 -- Removed faulty clearing of ordering field when missing from explicit grouping. Co-authored-by: Simon Charette --- django/db/models/sql/query.py | 8 +++++++- tests/aggregation_regress/tests.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py index 84950d4ec0..8f9349e7eb 100644 --- a/django/db/models/sql/query.py +++ b/django/db/models/sql/query.py @@ -2346,7 +2346,13 @@ class Query(BaseExpression): query (not even the model's default). """ if not force and ( - self.is_sliced or self.distinct_fields or self.select_for_update + self.is_sliced + or self.distinct_fields + or self.select_for_update + or ( + isinstance(self.group_by, tuple) + and not {*self.order_by, *self.extra_order_by}.issubset(self.group_by) + ) ): return self.order_by = () diff --git a/tests/aggregation_regress/tests.py b/tests/aggregation_regress/tests.py index 3e1a6a71f9..5f17169dc6 100644 --- a/tests/aggregation_regress/tests.py +++ b/tests/aggregation_regress/tests.py @@ -171,6 +171,25 @@ class AggregationTests(TestCase): for attr, value in kwargs.items(): self.assertEqual(getattr(obj, attr), value) + def test_count_preserve_group_by(self): + # new release of the same book + Book.objects.create( + isbn="113235613", + name=self.b4.name, + pages=self.b4.pages, + rating=4.0, + price=Decimal("39.69"), + contact=self.a5, + publisher=self.p3, + pubdate=datetime.date(2018, 11, 3), + ) + qs = Book.objects.values("contact__name", "publisher__name").annotate( + publications=Count("id") + ) + self.assertEqual(qs.count(), Book.objects.count() - 1) + self.assertEqual(qs.order_by("id").count(), Book.objects.count()) + self.assertEqual(qs.extra(order_by=["id"]).count(), Book.objects.count()) + def test_annotation_with_value(self): values = ( Book.objects.filter( From 6436ec321073bf0622af815e0af08f54c97f9b30 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Wed, 22 Oct 2025 12:42:00 -0400 Subject: [PATCH 102/116] Fixed #36680 -- Parametrized formatter discovery in AdminScriptTestCase. --- tests/admin_scripts/tests.py | 62 ++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/tests/admin_scripts/tests.py b/tests/admin_scripts/tests.py index 3718354dc7..9442822bd6 100644 --- a/tests/admin_scripts/tests.py +++ b/tests/admin_scripts/tests.py @@ -33,11 +33,13 @@ from django.core.management.base import LabelCommand, SystemCheckError from django.core.management.commands.loaddata import Command as LoaddataCommand from django.core.management.commands.runserver import Command as RunserverCommand from django.core.management.commands.testserver import Command as TestserverCommand +from django.core.management.utils import find_formatters from django.db import ConnectionHandler, connection from django.db.migrations.recorder import MigrationRecorder from django.test import LiveServerTestCase, SimpleTestCase, TestCase, override_settings from django.test.utils import captured_stderr, captured_stdout from django.urls import path +from django.utils.functional import cached_property from django.utils.version import PY313, get_docs_version from django.views.static import serve @@ -47,8 +49,6 @@ custom_templates_dir = os.path.join(os.path.dirname(__file__), "custom_templates SYSTEM_CHECK_MSG = "System check identified no issues" -HAS_BLACK = shutil.which("black") - class AdminScriptTestCase(SimpleTestCase): def setUp(self): @@ -112,7 +112,20 @@ class AdminScriptTestCase(SimpleTestCase): paths.append(os.path.dirname(backend_dir)) return paths - def run_test(self, args, settings_file=None, apps=None, umask=-1): + @cached_property + def path_without_formatters(self): + return os.pathsep.join( + [ + path_component + for path_component in os.environ.get("PATH", "").split(os.pathsep) + for formatter_path in find_formatters().values() + if os.path.commonpath([path_component, formatter_path]) == os.sep + ] + ) + + def run_test( + self, args, settings_file=None, apps=None, umask=-1, discover_formatters=False + ): base_dir = os.path.dirname(self.test_dir) # The base dir for Django's tests is one level up. tests_dir = os.path.dirname(os.path.dirname(__file__)) @@ -134,6 +147,8 @@ class AdminScriptTestCase(SimpleTestCase): python_path.extend(ext_backend_base_dirs) test_environ["PYTHONPATH"] = os.pathsep.join(python_path) test_environ["PYTHONWARNINGS"] = "" + if not discover_formatters: + test_environ["PATH"] = self.path_without_formatters p = subprocess.run( [sys.executable, *args], @@ -145,10 +160,19 @@ class AdminScriptTestCase(SimpleTestCase): ) return p.stdout, p.stderr - def run_django_admin(self, args, settings_file=None, umask=-1): - return self.run_test(["-m", "django", *args], settings_file, umask=umask) + def run_django_admin( + self, args, settings_file=None, umask=-1, discover_formatters=False + ): + return self.run_test( + ["-m", "django", *args], + settings_file, + umask=umask, + discover_formatters=discover_formatters, + ) - def run_manage(self, args, settings_file=None, manage_py=None): + def run_manage( + self, args, settings_file=None, manage_py=None, discover_formatters=False + ): template_manage_py = ( os.path.join(os.path.dirname(__file__), manage_py) if manage_py @@ -167,7 +191,11 @@ class AdminScriptTestCase(SimpleTestCase): with open(test_manage_py, "w") as fp: fp.write(manage_py_contents) - return self.run_test(["./manage.py", *args], settings_file) + return self.run_test( + ["./manage.py", *args], + settings_file, + discover_formatters=discover_formatters, + ) def assertNoOutput(self, stream): "Utility assertion: assert that the given stream is empty" @@ -744,10 +772,7 @@ class DjangoAdminSettingsDirectory(AdminScriptTestCase): with open(os.path.join(app_path, "apps.py")) as f: content = f.read() self.assertIn("class SettingsTestConfig(AppConfig)", content) - self.assertIn( - 'name = "settings_test"' if HAS_BLACK else "name = 'settings_test'", - content, - ) + self.assertIn("name = 'settings_test'", content) def test_setup_environ_custom_template(self): """ @@ -772,9 +797,7 @@ class DjangoAdminSettingsDirectory(AdminScriptTestCase): with open(os.path.join(app_path, "apps.py"), encoding="utf8") as f: content = f.read() self.assertIn("class こんにちはConfig(AppConfig)", content) - self.assertIn( - 'name = "こんにちは"' if HAS_BLACK else "name = 'こんにちは'", content - ) + self.assertIn("name = 'こんにちは'", content) def test_builtin_command(self): """ @@ -1936,7 +1959,7 @@ class CommandTypes(AdminScriptTestCase): def test_version(self): "version is handled as a special case" args = ["version"] - out, err = self.run_manage(args) + out, err = self.run_manage(args, discover_formatters=True) self.assertNoOutput(err) self.assertOutput(out, get_version()) @@ -2689,7 +2712,7 @@ class StartProject(LiveServerTestCase, AdminScriptTestCase): args = ["startproject", "--template", template_path, "customtestproject"] testproject_dir = os.path.join(self.test_dir, "customtestproject") - _, err = self.run_django_admin(args) + _, err = self.run_django_admin(args, discover_formatters=True) self.assertNoOutput(err) with open( os.path.join(template_path, "additional_dir", "requirements.in") @@ -2784,7 +2807,7 @@ class StartProject(LiveServerTestCase, AdminScriptTestCase): f"{self.live_server_url}/user_agent_check/project_template.tgz" ) args = ["startproject", "--template", template_url, "urltestproject"] - _, err = self.run_django_admin(args) + _, err = self.run_django_admin(args, discover_formatters=True) self.assertNoOutput(err) self.assertIn("Django/%s" % get_version(), user_agent) @@ -3126,10 +3149,7 @@ class StartApp(AdminScriptTestCase): with open(os.path.join(app_path, "apps.py")) as f: content = f.read() self.assertIn("class NewAppConfig(AppConfig)", content) - self.assertIn( - 'name = "new_app"' if HAS_BLACK else "name = 'new_app'", - content, - ) + self.assertIn("name = 'new_app'", content) def test_creates_directory_when_custom_app_destination_missing(self): args = [ From 9ba3f74a46d15f9f2f45ad4ef8cdd245a888e58e Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Tue, 28 Oct 2025 11:21:52 +0100 Subject: [PATCH 103/116] Fixed #36596 -- Made parallel test runner respect django_test_skips and django_test_expected_failures. --- django/db/backends/sqlite3/creation.py | 2 -- django/test/runner.py | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/django/db/backends/sqlite3/creation.py b/django/db/backends/sqlite3/creation.py index 8a07e0c417..d57bf9ee1f 100644 --- a/django/db/backends/sqlite3/creation.py +++ b/django/db/backends/sqlite3/creation.py @@ -155,5 +155,3 @@ class DatabaseCreation(BaseDatabaseCreation): # connection. self.connection.connect() target_db.close() - if os.environ.get("RUNNING_DJANGOS_TEST_SUITE") == "true": - self.mark_expected_failures_and_skips() diff --git a/django/test/runner.py b/django/test/runner.py index 41c9dbd10c..d0367ba71e 100644 --- a/django/test/runner.py +++ b/django/test/runner.py @@ -473,6 +473,11 @@ def _init_worker( if value := serialized_contents.get(alias): connection._test_serialized_contents = value connection.creation.setup_worker_connection(_worker_id) + if ( + is_spawn_or_forkserver + and os.environ.get("RUNNING_DJANGOS_TEST_SUITE") == "true" + ): + connection.creation.mark_expected_failures_and_skips() if is_spawn_or_forkserver: call_command( From 43933a1dca07047e95ec990d9289d0212668009e Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Tue, 28 Oct 2025 10:43:54 -0400 Subject: [PATCH 104/116] Reverted "Fixed #26434 -- Removed faulty clearing of ordering field when missing from explicit grouping." This reverts commit ea3a71c2d09f8281d8a50ed20e40e1fb13db5cd9. The implementation was flawed, as self.group_by contains Cols, not aliases. --- django/db/models/sql/query.py | 8 +------- tests/aggregation_regress/tests.py | 19 ------------------- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py index 8f9349e7eb..84950d4ec0 100644 --- a/django/db/models/sql/query.py +++ b/django/db/models/sql/query.py @@ -2346,13 +2346,7 @@ class Query(BaseExpression): query (not even the model's default). """ if not force and ( - self.is_sliced - or self.distinct_fields - or self.select_for_update - or ( - isinstance(self.group_by, tuple) - and not {*self.order_by, *self.extra_order_by}.issubset(self.group_by) - ) + self.is_sliced or self.distinct_fields or self.select_for_update ): return self.order_by = () diff --git a/tests/aggregation_regress/tests.py b/tests/aggregation_regress/tests.py index 5f17169dc6..3e1a6a71f9 100644 --- a/tests/aggregation_regress/tests.py +++ b/tests/aggregation_regress/tests.py @@ -171,25 +171,6 @@ class AggregationTests(TestCase): for attr, value in kwargs.items(): self.assertEqual(getattr(obj, attr), value) - def test_count_preserve_group_by(self): - # new release of the same book - Book.objects.create( - isbn="113235613", - name=self.b4.name, - pages=self.b4.pages, - rating=4.0, - price=Decimal("39.69"), - contact=self.a5, - publisher=self.p3, - pubdate=datetime.date(2018, 11, 3), - ) - qs = Book.objects.values("contact__name", "publisher__name").annotate( - publications=Count("id") - ) - self.assertEqual(qs.count(), Book.objects.count() - 1) - self.assertEqual(qs.order_by("id").count(), Book.objects.count()) - self.assertEqual(qs.extra(order_by=["id"]).count(), Book.objects.count()) - def test_annotation_with_value(self): values = ( Book.objects.filter( From 787cc96ef6197d73c7d4ad96f25500910c399603 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Mon, 27 Oct 2025 12:27:30 -0400 Subject: [PATCH 105/116] Refs #35972 -- Returned params in a tuple in further lookups. --- django/contrib/gis/db/models/lookups.py | 2 +- django/db/models/fields/json.py | 6 +++--- django/db/models/lookups.py | 6 +++--- django/db/models/sql/compiler.py | 4 ++-- tests/custom_lookups/tests.py | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/django/contrib/gis/db/models/lookups.py b/django/contrib/gis/db/models/lookups.py index b9e5e47b27..c0cb80ea70 100644 --- a/django/contrib/gis/db/models/lookups.py +++ b/django/contrib/gis/db/models/lookups.py @@ -19,7 +19,7 @@ class GISLookup(Lookup): band_lhs = None def __init__(self, lhs, rhs): - rhs, *self.rhs_params = rhs if isinstance(rhs, (list, tuple)) else [rhs] + rhs, *self.rhs_params = rhs if isinstance(rhs, (list, tuple)) else (rhs,) super().__init__(lhs, rhs) self.template_params = {} self.process_rhs_params() diff --git a/django/db/models/fields/json.py b/django/db/models/fields/json.py index e0aa5c622b..af5ec4c8b0 100644 --- a/django/db/models/fields/json.py +++ b/django/db/models/fields/json.py @@ -316,8 +316,8 @@ class JSONExact(lookups.Exact): def process_rhs(self, compiler, connection): rhs, rhs_params = super().process_rhs(compiler, connection) # Treat None lookup values as null. - if rhs == "%s" and rhs_params == [None]: - rhs_params = ["null"] + if rhs == "%s" and (*rhs_params,) == (None,): + rhs_params = ("null",) if connection.vendor == "mysql": func = ["JSON_EXTRACT(%s, '$')"] * len(rhs_params) rhs %= tuple(func) @@ -552,7 +552,7 @@ class KeyTransformExact(JSONExact): def as_oracle(self, compiler, connection): rhs, rhs_params = super().process_rhs(compiler, connection) - if rhs_params == ["null"]: + if rhs_params and (*rhs_params,) == ("null",): # Field has key and it's NULL. has_key_expr = HasKeyOrArrayIndex(self.lhs.lhs, self.lhs.key_name) has_key_sql, has_key_params = has_key_expr.as_oracle(compiler, connection) diff --git a/django/db/models/lookups.py b/django/db/models/lookups.py index 65128732fd..f15147a60b 100644 --- a/django/db/models/lookups.py +++ b/django/db/models/lookups.py @@ -232,12 +232,12 @@ class BuiltinLookup(Lookup): lhs_sql = ( connection.ops.lookup_cast(self.lookup_name, field_internal_type) % lhs_sql ) - return lhs_sql, list(params) + return lhs_sql, tuple(params) def as_sql(self, compiler, connection): lhs_sql, params = self.process_lhs(compiler, connection) rhs_sql, rhs_params = self.process_rhs(compiler, connection) - params.extend(rhs_params) + params = (*params, *rhs_params) rhs_sql = self.get_rhs_op(connection, rhs_sql) return "%s %s" % (lhs_sql, rhs_sql), params @@ -725,7 +725,7 @@ class YearLookup(Lookup): rhs_sql, _ = self.process_rhs(compiler, connection) rhs_sql = self.get_direct_rhs_sql(connection, rhs_sql) start, finish = self.year_lookup_bounds(connection, self.rhs) - params.extend(self.get_bound_params(start, finish)) + params = (*params, *self.get_bound_params(start, finish)) return "%s %s" % (lhs_sql, rhs_sql), params return super().as_sql(compiler, connection) diff --git a/django/db/models/sql/compiler.py b/django/db/models/sql/compiler.py index 14603d5773..20f06ad168 100644 --- a/django/db/models/sql/compiler.py +++ b/django/db/models/sql/compiler.py @@ -1703,8 +1703,8 @@ class SQLInsertCompiler(SQLCompiler): sql, params = "%s", [val] # The following hook is only used by Oracle Spatial, which sometimes - # needs to yield 'NULL' and [] as its placeholder and params instead - # of '%s' and [None]. The 'NULL' placeholder is produced earlier by + # needs to yield 'NULL' and () as its placeholder and params instead + # of '%s' and (None,). The 'NULL' placeholder is produced earlier by # OracleOperations.get_geom_placeholder(). The following line removes # the corresponding None parameter. See ticket #10888. params = self.connection.ops.modify_insert_params(sql, params) diff --git a/tests/custom_lookups/tests.py b/tests/custom_lookups/tests.py index db985240d7..1be289b18a 100644 --- a/tests/custom_lookups/tests.py +++ b/tests/custom_lookups/tests.py @@ -119,7 +119,7 @@ class YearLte(models.lookups.LessThanOrEqual): real_lhs = self.lhs.lhs lhs_sql, params = self.process_lhs(compiler, connection, real_lhs) rhs_sql, rhs_params = self.process_rhs(compiler, connection) - params.extend(rhs_params) + params = (*params, *rhs_params) # Build SQL where the integer year is concatenated with last month # and day, then convert that to date. (We try to have SQL like: # WHERE somecol <= '2013-12-31') From 1aa69a7491ce7f7f1f164a26a3dfaaa1aeeab217 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Tue, 21 Oct 2025 19:09:32 -0400 Subject: [PATCH 106/116] Fixed #36678 -- Limited retries in ParallelTestRunner. Thanks Natalia Bidart for the review. --- django/test/runner.py | 19 +++++++++++++++++-- tests/test_runner/tests.py | 7 +++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/django/test/runner.py b/django/test/runner.py index d0367ba71e..ecae164d7f 100644 --- a/django/test/runner.py +++ b/django/test/runner.py @@ -1,6 +1,7 @@ import argparse import ctypes import faulthandler +import functools import hashlib import io import itertools @@ -485,6 +486,16 @@ def _init_worker( ) +def _safe_init_worker(init_worker, counter, *args, **kwargs): + try: + init_worker(counter, *args, **kwargs) + except Exception: + with counter.get_lock(): + # Set a value that will not increment above zero any time soon. + counter.value = -1000 + raise + + def _run_subsuite(args): """ Run a suite of tests with a RemoteTestRunner and return a RemoteTestResult. @@ -558,7 +569,7 @@ class ParallelTestSuite(unittest.TestSuite): counter = multiprocessing.Value(ctypes.c_int, 0) pool = multiprocessing.Pool( processes=self.processes, - initializer=self.init_worker.__func__, + initializer=functools.partial(_safe_init_worker, self.init_worker.__func__), initargs=[ counter, self.initial_settings, @@ -585,7 +596,11 @@ class ParallelTestSuite(unittest.TestSuite): try: subsuite_index, events = test_results.next(timeout=0.1) - except multiprocessing.TimeoutError: + except multiprocessing.TimeoutError as err: + if counter.value < 0: + err.add_note("ERROR: _init_worker failed, see prior traceback") + pool.close() + raise continue except StopIteration: pool.close() diff --git a/tests/test_runner/tests.py b/tests/test_runner/tests.py index 2c1fc3ad68..9173fa5d36 100644 --- a/tests/test_runner/tests.py +++ b/tests/test_runner/tests.py @@ -3,6 +3,7 @@ Tests for django test runner """ import collections.abc +import functools import multiprocessing import os import sys @@ -738,8 +739,10 @@ class TestRunnerInitializerTests(SimpleTestCase): "test_runner_apps.simple.tests", ] ) - # Initializer must be a function. - self.assertIs(mocked_pool.call_args.kwargs["initializer"], _init_worker) + # Initializer must be a partial function binding _init_worker. + initializer = mocked_pool.call_args.kwargs["initializer"] + self.assertIsInstance(initializer, functools.partial) + self.assertIs(initializer.args[0], _init_worker) initargs = mocked_pool.call_args.kwargs["initargs"] self.assertEqual(len(initargs), 7) self.assertEqual(initargs[5], True) # debug_mode From 01f8460653e73a8f60c98d3a37a74b28818744b6 Mon Sep 17 00:00:00 2001 From: Clifford Gama Date: Wed, 29 Oct 2025 17:32:12 +0200 Subject: [PATCH 107/116] Fixed #36329 -- Removed non-code custom link text when cross-referencing Python objects. Thanks Bruno Alla, Sarah Boyce, and Jacob Walls for reviews. Co-authored-by: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> --- docs/faq/install.txt | 8 ++-- docs/howto/error-reporting.txt | 2 +- docs/howto/logging.txt | 6 +-- docs/intro/tutorial07.txt | 17 ++++---- docs/ref/contrib/auth.txt | 6 +-- docs/ref/contrib/contenttypes.txt | 12 +++--- docs/ref/contrib/postgres/search.txt | 6 +-- docs/ref/django-admin.txt | 6 +-- docs/ref/models/expressions.txt | 4 +- docs/ref/models/fields.txt | 12 +++--- docs/ref/models/indexes.txt | 8 ++-- docs/ref/models/instances.txt | 12 +++--- docs/ref/models/meta.txt | 17 ++++---- docs/ref/models/querysets.txt | 39 +++++++++--------- docs/ref/settings.txt | 13 +++--- docs/topics/auth/default.txt | 12 +++--- docs/topics/class-based-views/mixins.txt | 13 +++--- docs/topics/composite-primary-key.txt | 11 +++--- docs/topics/db/models.txt | 4 +- docs/topics/db/optimization.txt | 26 ++++++------ docs/topics/db/queries.txt | 32 +++++++-------- docs/topics/forms/formsets.txt | 5 ++- docs/topics/forms/modelforms.txt | 12 +++--- docs/topics/http/shortcuts.txt | 4 +- docs/topics/i18n/translation.txt | 2 +- docs/topics/testing/tools.txt | 50 +++++++++++------------- 26 files changed, 168 insertions(+), 171 deletions(-) diff --git a/docs/faq/install.txt b/docs/faq/install.txt index 6f49bbe22b..23fb6dcd90 100644 --- a/docs/faq/install.txt +++ b/docs/faq/install.txt @@ -25,10 +25,10 @@ needed. For a development environment -- if you just want to experiment with Django -- you don't need to have a separate web server installed or database server. -Django comes with its own :djadmin:`lightweight development server`. -For a production environment, Django follows the WSGI spec, :pep:`3333`, which -means it can run on a variety of web servers. See :doc:`Deploying Django -` for more information. +Django comes with its own lightweight development server +(:djadmin:`runserver`). For a production environment, Django follows the WSGI +spec, :pep:`3333`, which means it can run on a variety of web servers. See +:doc:`/howto/deployment/index` for more information. Django runs `SQLite`_ by default, which is included in Python installations. For a production environment, we recommend PostgreSQL_; but we also officially diff --git a/docs/howto/error-reporting.txt b/docs/howto/error-reporting.txt index 81a3e7e575..faff166577 100644 --- a/docs/howto/error-reporting.txt +++ b/docs/howto/error-reporting.txt @@ -195,7 +195,7 @@ filtered out of error reports in a production environment (that is, where .. function:: sensitive_post_parameters(*parameters) If one of your views receives an :class:`~django.http.HttpRequest` object - with :attr:`POST parameters` susceptible to + with :attr:`~django.http.HttpRequest.POST` parameters susceptible to contain sensitive information, you may prevent the values of those parameters from being included in the error reports using the ``sensitive_post_parameters`` decorator:: diff --git a/docs/howto/logging.txt b/docs/howto/logging.txt index 7826eb38d9..fa9b7a61b2 100644 --- a/docs/howto/logging.txt +++ b/docs/howto/logging.txt @@ -186,9 +186,9 @@ root of the project. Configure a formatter ~~~~~~~~~~~~~~~~~~~~~ -By default, the final log output contains the message part of each :class:`log -record `. Use a formatter if you want to include additional -data. First name and define your formatters - this example defines +By default, the final log output contains the message part of each +:class:`~logging.LogRecord` object. Use a formatter if you want to include +additional data. First name and define your formatters - this example defines formatters named ``verbose`` and ``simple``: .. code-block:: python diff --git a/docs/intro/tutorial07.txt b/docs/intro/tutorial07.txt index b7638e365c..ab77f2b540 100644 --- a/docs/intro/tutorial07.txt +++ b/docs/intro/tutorial07.txt @@ -284,13 +284,16 @@ the scenes, limiting the number of search fields to a reasonable number will make it easier for your database to do the search. Now's also a good time to note that change lists give you free pagination. The -default is to display 100 items per page. :attr:`Change list pagination -`, :attr:`search boxes -`, :attr:`filters -`, :attr:`date-hierarchies -`, and -:attr:`column-header-ordering ` -all work together like you think they should. +default is to display 100 items per page. + +.. seealso:: + + The following :class:`~django.contrib.admin.ModelAdmin` options allow + further customization of change lists: + :attr:`~django.contrib.admin.ModelAdmin.list_per_page`, + :attr:`~django.contrib.admin.ModelAdmin.search_fields`, + :attr:`~django.contrib.admin.ModelAdmin.date_hierarchy`, and + :attr:`~django.contrib.admin.ModelAdmin.list_display`. Customize the admin look and feel ================================= diff --git a/docs/ref/contrib/auth.txt b/docs/ref/contrib/auth.txt index 4438182cb0..ff16de6423 100644 --- a/docs/ref/contrib/auth.txt +++ b/docs/ref/contrib/auth.txt @@ -646,10 +646,8 @@ The following backends are available in :mod:`django.contrib.auth.backends`: .. method:: user_can_authenticate() Returns whether the user is allowed to authenticate. To match the - behavior of :class:`~django.contrib.auth.forms.AuthenticationForm` - which :meth:`prohibits inactive users from logging in - `, - this method returns ``False`` for users with :attr:`is_active=False + behavior of :meth:`.AuthenticationForm.confirm_login_allowed`, this + method returns ``False`` for users with :attr:`is_active=False `. Custom user models that don't have an :attr:`~django.contrib.auth.models.CustomUser.is_active` field are allowed. diff --git a/docs/ref/contrib/contenttypes.txt b/docs/ref/contrib/contenttypes.txt index 72bee36a08..9a6b14ef4c 100644 --- a/docs/ref/contrib/contenttypes.txt +++ b/docs/ref/contrib/contenttypes.txt @@ -110,8 +110,7 @@ model it represents, or to retrieve objects from that model: Takes a set of valid :ref:`lookup arguments ` for the model the :class:`~django.contrib.contenttypes.models.ContentType` - represents, and does - :meth:`a get() lookup ` + represents, and does a :meth:`~django.db.models.query.QuerySet.get` lookup on that model, returning the corresponding object. The ``using`` argument can be used to specify a different database than the default one. @@ -160,11 +159,10 @@ two extremely important use cases: to get access to those model classes. Several of Django's bundled applications make use of the latter technique. -For example, -:class:`the permissions system ` in -Django's authentication framework uses a -:class:`~django.contrib.auth.models.Permission` model with a foreign -key to :class:`~django.contrib.contenttypes.models.ContentType`; this lets +For example, :ref:`the permissions system ` in Django's +authentication framework uses a :class:`~django.contrib.auth.models.Permission` +model with a foreign key to +:class:`~django.contrib.contenttypes.models.ContentType`; this lets :class:`~django.contrib.auth.models.Permission` represent concepts like "can add blog entry" or "can delete news story". diff --git a/docs/ref/contrib/postgres/search.txt b/docs/ref/contrib/postgres/search.txt index 0d14bc8c05..15f4909e0f 100644 --- a/docs/ref/contrib/postgres/search.txt +++ b/docs/ref/contrib/postgres/search.txt @@ -338,9 +338,9 @@ than comparing the size of an integer, for example. In the event that all the fields you're querying on are contained within one particular model, you can create a functional -:class:`GIN ` or -:class:`GiST ` index which matches -the search vector you wish to use. For example:: +:class:`~django.contrib.postgres.indexes.GinIndex` or +:class:`~django.contrib.postgres.indexes.GistIndex` which matches the search +vector you wish to use. For example:: GinIndex( SearchVector("body_text", "headline", config="english"), diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index 24367e5871..030d2f28dd 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -1337,8 +1337,8 @@ Specifies which directories in the app template should be excluded, in addition to ``.git`` and ``__pycache__``. If this option is not provided, directories named ``__pycache__`` or starting with ``.`` will be excluded. -The :class:`template context ` used for all matching -files is: +The template context (see :class:`~django.template.Context`) used for all +matching files is: - Any option passed to the ``startapp`` command (among the command's supported options) @@ -1435,7 +1435,7 @@ Specifies which directories in the project template should be excluded, in addition to ``.git`` and ``__pycache__``. If this option is not provided, directories named ``__pycache__`` or starting with ``.`` will be excluded. -The :class:`template context ` used is: +The template context (see :class:`~django.template.Context`) used is: - Any option passed to the ``startproject`` command (among the command's supported options) diff --git a/docs/ref/models/expressions.txt b/docs/ref/models/expressions.txt index b542fe7d71..fa5b4b9540 100644 --- a/docs/ref/models/expressions.txt +++ b/docs/ref/models/expressions.txt @@ -111,6 +111,8 @@ Built-in Expressions ``django.db.models.aggregates``, but for convenience they're available and usually imported from :mod:`django.db.models`. +.. _f-expressions: + ``F()`` expressions ------------------- @@ -493,7 +495,7 @@ should be invoked for each distinct value of ``expressions`` (or set of values, for multiple ``expressions``). The argument is only supported on aggregates that have :attr:`~Aggregate.allow_distinct` set to ``True``. -The ``filter`` argument takes a :class:`Q object ` that's +The ``filter`` argument takes a :ref:`Q object ` that's used to filter the rows that are aggregated. See :ref:`conditional-aggregation` and :ref:`filtering-on-annotations` for example usage. diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 0b62143cc0..4e515d428e 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -561,8 +561,8 @@ The primary key field is read-only. If you change the value of the primary key on an existing object and then save it, a new object will be created alongside the old one. -The primary key field is set to ``None`` when -:meth:`deleting ` an object. +The primary key field is set to ``None`` when calling a model instance’s +:meth:`~django.db.models.Model.delete` method. ``unique`` ---------- @@ -2436,9 +2436,9 @@ Field API reference Python types to database (:meth:`get_prep_value`) and vice-versa (:meth:`from_db_value`). - A field is thus a fundamental piece in different Django APIs, notably, - :class:`models ` and :class:`querysets - `. + A field is thus a fundamental piece in different Django APIs, notably the + :class:`~django.db.models.Model` and the + :class:`~django.db.models.query.QuerySet` APIs. In models, a field is instantiated as a class attribute and represents a particular table column, see :doc:`/topics/db/models`. It has attributes @@ -2598,7 +2598,7 @@ Field API reference See :ref:`converting-model-field-to-serialization` for usage. - When using :class:`model forms `, the ``Field`` + When using :doc:`model forms `, the ``Field`` needs to know which form field it should be represented by: .. method:: formfield(form_class=None, choices_form_class=None, **kwargs) diff --git a/docs/ref/models/indexes.txt b/docs/ref/models/indexes.txt index 6f8dd39fb7..c9de422ad3 100644 --- a/docs/ref/models/indexes.txt +++ b/docs/ref/models/indexes.txt @@ -211,10 +211,10 @@ filtering. See the PostgreSQL documentation for more details about `covering indexes`_. -.. admonition:: Restrictions on PostgreSQL +.. admonition:: PostgreSQL-specific indexes - PostgreSQL supports covering B-Tree, :class:`GiST indexes - `, and :class:`SP-GiST indexes - `. + In addition to B-Tree indexes (via :class:`Index`), PostgreSQL also + supports :class:`~django.contrib.postgres.indexes.GistIndex` and + :class:`~django.contrib.postgres.indexes.SpGistIndex` indexes. .. _covering indexes: https://www.postgresql.org/docs/current/indexes-index-only-scans.html diff --git a/docs/ref/models/instances.txt b/docs/ref/models/instances.txt index a8be767aaf..323de16b1f 100644 --- a/docs/ref/models/instances.txt +++ b/docs/ref/models/instances.txt @@ -632,10 +632,9 @@ the value of 11 will be written back to the database. The process can be made robust, :ref:`avoiding a race condition `, as well as slightly faster by expressing the update relative to the original field value, rather than as an explicit -assignment of a new value. Django provides :class:`F expressions -` for performing this kind of relative update. Using -:class:`F expressions `, the previous example is expressed -as: +assignment of a new value. Django provides :ref:`f-expressions` for performing +this kind of relative update. Using :ref:`f-expressions`, the previous example +is expressed as: .. code-block:: pycon @@ -644,9 +643,8 @@ as: >>> product.number_sold = F("number_sold") + 1 >>> product.save() -For more details, see the documentation on :class:`F expressions -` and their :ref:`use in update queries -`. +For more details, see the documentation on :ref:`f-expressions` and their +:ref:`use in update queries `. .. _ref-models-update-fields: diff --git a/docs/ref/models/meta.txt b/docs/ref/models/meta.txt index 8aa553f073..4595362a4d 100644 --- a/docs/ref/models/meta.txt +++ b/docs/ref/models/meta.txt @@ -38,8 +38,8 @@ Retrieving a single field instance of a model by name user, the :attr:`~.ForeignKey.related_name` set by the user, or the name automatically generated by Django. - :attr:`Hidden fields ` cannot be retrieved - by name. + Hidden fields, fields with :attr:`hidden=True + `, cannot be retrieved by name. If a field with the given name is not found a :class:`~django.core.exceptions.FieldDoesNotExist` exception will be @@ -80,7 +80,7 @@ Retrieving all field instances of a model ``include_hidden`` ``False`` by default. If set to ``True``, ``get_fields()`` will include - :attr:`hidden fields `. + fields with :attr:`hidden=True `. .. code-block:: pycon @@ -127,9 +127,8 @@ Retrieving fields composing the primary key of a model Returns a list of the fields composing the primary key of a model. - When a :class:`composite primary key - ` is defined on a model it will - contain all the :class:`fields ` referenced by it. + When a :class:`~django.db.models.CompositePrimaryKey` is defined on a model + it will contain all the fields referenced by it. .. code-block:: python @@ -149,8 +148,10 @@ Retrieving fields composing the primary key of a model ] - Otherwise it will contain the single field declared as the - :attr:`primary key ` of the model. + Otherwise it will contain the single field declared as the primary key of + the model, either explicitly with :attr:`primary_key=True + ` or implicitly as the :ref:`automatic + primary key `. .. code-block:: pycon diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 3e7024211e..e494739bcd 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -209,7 +209,7 @@ The lookup parameters (``**kwargs``) should be in the format described in underlying SQL statement. If you need to execute more complex queries (for example, queries with ``OR`` -statements), you can use :class:`Q objects ` (``*args``). +statements), you can use :ref:`q-objects` (``*args``). ``exclude()`` ~~~~~~~~~~~~~ @@ -259,8 +259,8 @@ statements), you can use :class:`Q objects ` (``*args``). .. method:: annotate(*args, **kwargs) Annotates each object in the ``QuerySet`` with the provided list of :doc:`query -expressions ` or :class:`~django.db.models.Q` objects. -Each object can be annotated with: +expressions ` or :ref:`q-objects`. Each object can be +annotated with: * a simple value, via ``Value()``; * a reference to a field on the model (or any related models), via ``F()``; @@ -1276,9 +1276,9 @@ database. :meth:`~django.db.models.fields.related.RelatedManager.create`, :meth:`~django.db.models.fields.related.RelatedManager.remove`, :meth:`~django.db.models.fields.related.RelatedManager.clear` or - :meth:`~django.db.models.fields.related.RelatedManager.set`, on - :class:`related managers`, - any prefetched cache for the relation will be cleared. + :meth:`~django.db.models.fields.related.RelatedManager.set`, on a + :class:`~django.db.models.fields.related.RelatedManager`, any prefetched + cache for the relation will be cleared. You can also use the normal join syntax to do related fields of related fields. Suppose we have an additional model to the example above:: @@ -1433,8 +1433,8 @@ where prefetching with a custom ``QuerySet`` is useful: * You want to prefetch only a subset of the related objects. -* You want to use performance optimization techniques like - :meth:`deferred fields `: +* You want to use performance optimization techniques like deferring fields, + for example, via :meth:`defer` or :meth:`only`: .. code-block:: pycon @@ -1797,11 +1797,10 @@ will always be fetched into the resulting queryset. normalize your models and put the non-loaded data into a separate model (and database table). If the columns *must* stay in the one table for some reason, create a model with ``Meta.managed = False`` (see the - :attr:`managed attribute ` documentation) - containing just the fields you normally need to load and use that where you - might otherwise call ``defer()``. This makes your code more explicit to the - reader, is slightly faster and consumes a little less memory in the Python - process. + :attr:`~django.db.models.Options.managed` documentation) containing just + the fields you normally need to load and use that where you might otherwise + call ``defer()``. This makes your code more explicit to the reader, is + slightly faster and consumes a little less memory in the Python process. For example, both of these models use the same underlying database table:: @@ -2266,9 +2265,9 @@ found, ``get_or_create()`` returns a tuple of that object and ``False``. inserted. You can specify more complex conditions for the retrieved object by chaining -``get_or_create()`` with ``filter()`` and using :class:`Q objects -`. For example, to retrieve Robert or Bob Marley if either -exists, and create the latter otherwise:: +``get_or_create()`` with ``filter()`` and using :ref:`q-objects`. For example, +to retrieve Robert or Bob Marley if either exists, and create the latter +otherwise:: from django.db.models import Q @@ -3963,8 +3962,8 @@ An optional argument that represents the :doc:`model field ``filter`` ~~~~~~~~~~ -An optional :class:`Q object ` that's used to filter the -rows that are aggregated. +An optional :ref:`Q object ` that's used to filter the rows that +are aggregated. See :ref:`conditional-aggregation` and :ref:`filtering-on-annotations` for example usage. @@ -4178,6 +4177,8 @@ Query-related tools This section provides reference material for query-related tools not documented elsewhere. +.. _q-objects: + ``Q()`` objects --------------- @@ -4282,7 +4283,7 @@ overridden by using a custom queryset in a related lookup. .. attribute:: FilteredRelation.condition - A :class:`~django.db.models.Q` object to control the filtering. + A :ref:`Q object ` to control the filtering. ``FilteredRelation`` is used with :meth:`~.QuerySet.annotate` to create an ``ON`` clause when a ``JOIN`` is performed. It doesn't act on the default diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index b0750d3a42..8ac7a194f6 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -463,9 +463,9 @@ A list of trusted origins for unsafe requests (e.g. ``POST``). For requests that include the ``Origin`` header, Django's CSRF protection requires that header match the origin present in the ``Host`` header. -For a :meth:`secure ` unsafe -request that doesn't include the ``Origin`` header, the request must have a -``Referer`` header that matches the origin present in the ``Host`` header. +For a secure (determined by :meth:`~django.http.HttpRequest.is_secure`) unsafe +request that doesn't include the ``Origin`` header, the request must include a +``Referer`` header that matches the origin in the ``Host`` header. These checks prevent, for example, a ``POST`` request from ``subdomain.example.com`` from succeeding against ``api.example.com``. If you @@ -1778,9 +1778,10 @@ Default: ``[]`` (Empty list) List of compiled regular expression objects describing URLs that should be ignored when reporting HTTP 404 errors via email (see :doc:`/howto/error-reporting`). Regular expressions are matched against -:meth:`request's full paths ` (including -query string, if any). Use this if your site does not provide a commonly -requested file such as ``favicon.ico`` or ``robots.txt``. +request's full paths, as returned by +:meth:`~django.http.HttpRequest.get_full_path` (including any query strings). +Use this if your site does not provide a commonly requested file such as +``favicon.ico`` or ``robots.txt``. This is only used if :class:`~django.middleware.common.BrokenLinkEmailsMiddleware` is enabled (see diff --git a/docs/topics/auth/default.txt b/docs/topics/auth/default.txt index c4adb1d1fc..f9d216e1df 100644 --- a/docs/topics/auth/default.txt +++ b/docs/topics/auth/default.txt @@ -37,8 +37,8 @@ The primary attributes of the default user are: * :attr:`~django.contrib.auth.models.User.first_name` * :attr:`~django.contrib.auth.models.User.last_name` -See the :class:`full API documentation ` for -full reference, the documentation that follows is more task oriented. +See the :class:`~django.contrib.auth.models.User` API documentation for a +complete reference. The documentation that follows is more task-oriented. .. _topics-auth-creating-users: @@ -368,7 +368,7 @@ Authentication in web requests ============================== Django uses :doc:`sessions ` and middleware to hook the -authentication system into :class:`request objects `. +authentication system into :class:`~django.http.HttpRequest` objects. These provide a :attr:`request.user ` attribute and a :meth:`request.auser ` async method @@ -1619,9 +1619,9 @@ provides several built-in forms located in :mod:`django.contrib.auth.forms`: .. class:: AdminPasswordChangeForm A form used in the admin interface to change a user's password, including - the ability to set an :meth:`unusable password - `, which blocks the - user from logging in with password-based authentication. + the ability to set an unusable password (via + :meth:`~django.contrib.auth.models.User.set_unusable_password`), which + blocks the user from logging in with password-based authentication. Takes the ``user`` as the first positional argument. diff --git a/docs/topics/class-based-views/mixins.txt b/docs/topics/class-based-views/mixins.txt index 619636d8b9..bf577972a9 100644 --- a/docs/topics/class-based-views/mixins.txt +++ b/docs/topics/class-based-views/mixins.txt @@ -156,12 +156,13 @@ it. To make a :class:`~django.template.response.TemplateResponse`, :class:`ListView` then uses -:class:`~django.views.generic.list.MultipleObjectTemplateResponseMixin`; as -with :class:`~django.views.generic.detail.SingleObjectTemplateResponseMixin` -above, this overrides ``get_template_names()`` to provide :meth:`a range of -options `, with -the most commonly-used being ``/_list.html``, with the -``_list`` part again being taken from the +:class:`~django.views.generic.list.MultipleObjectTemplateResponseMixin`. +As with :class:`~django.views.generic.detail.SingleObjectTemplateResponseMixin` +above, this overrides +:meth:`~django.views.generic.list.MultipleObjectTemplateResponseMixin.get_template_names` +to provide a range of options, with the most commonly-used being +``/_list.html``, with the ``_list`` part again +being taken from the :attr:`~django.views.generic.list.MultipleObjectTemplateResponseMixin.template_name_suffix` attribute. (The date based generic views use suffixes such as ``_archive``, ``_archive_year`` and so on to use different templates for the various diff --git a/docs/topics/composite-primary-key.txt b/docs/topics/composite-primary-key.txt index 736bed5255..c299e4528a 100644 --- a/docs/topics/composite-primary-key.txt +++ b/docs/topics/composite-primary-key.txt @@ -199,8 +199,7 @@ Building composite primary key ready applications Prior to the introduction of composite primary keys, the single field composing the primary key of a model could be retrieved by introspecting the -:attr:`primary key ` attribute of its -fields: +:attr:`~django.db.models.Field.primary_key` attribute of its fields: .. code-block:: pycon @@ -214,10 +213,10 @@ fields: Now that a primary key can be composed of multiple fields the -:attr:`primary key ` attribute can no -longer be relied upon to identify members of the primary key as it will be set -to ``False`` to maintain the invariant that at most one field per model will -have this attribute set to ``True``: +:attr:`~django.db.models.Field.primary_key` attribute can no longer be relied +upon to identify members of the primary key as it will be set to ``False`` to +maintain the invariant that at most one field per model will have this +attribute set to ``True``: .. code-block:: pycon diff --git a/docs/topics/db/models.txt b/docs/topics/db/models.txt index 89870adb32..8c53aa7058 100644 --- a/docs/topics/db/models.txt +++ b/docs/topics/db/models.txt @@ -968,8 +968,8 @@ See :ref:`ref-models-update-fields` for more details. Note that the :meth:`~Model.delete` method for an object is not necessarily called when :ref:`deleting objects in bulk using a - QuerySet ` or as a result of a :attr:`cascading - delete `. To ensure customized + QuerySet ` or as a result of a cascading delete + (see :attr:`~django.db.models.ForeignKey.on_delete`). To ensure customized delete logic gets executed, you can use :data:`~django.db.models.signals.pre_delete` and/or :data:`~django.db.models.signals.post_delete` signals. diff --git a/docs/topics/db/optimization.txt b/docs/topics/db/optimization.txt index e03c9c6354..9c4ba314ae 100644 --- a/docs/topics/db/optimization.txt +++ b/docs/topics/db/optimization.txt @@ -133,8 +133,8 @@ For instance: * At the most basic level, use :ref:`filter and exclude ` to do filtering in the database. -* Use :class:`F expressions ` to filter - based on other fields within the same model. +* Use :ref:`f-expressions` to filter based on other fields within the same + model. * Use :doc:`annotate to do aggregation in the database `. @@ -396,9 +396,8 @@ number of SQL queries. For example:: Entry.objects.create(headline="This is a test") Entry.objects.create(headline="This is only a test") -Note that there are a number of :meth:`caveats to this method -`, so make sure it's appropriate -for your use case. +Note that :meth:`~django.db.models.query.QuerySet.bulk_create` has several +caveats, so ensure it's appropriate for your use case. Update in bulk -------------- @@ -427,9 +426,8 @@ The following example:: entries[1].headline = "This is no longer a test" entries[1].save() -Note that there are a number of :meth:`caveats to this method -`, so make sure it's appropriate -for your use case. +Note that :meth:`~django.db.models.query.QuerySet.bulk_update` has several +caveats, so ensure it's appropriate for your use case. Insert in bulk -------------- @@ -491,12 +489,12 @@ objects to reduce the number of SQL queries. For example:: ...where ``Band`` and ``Artist`` are models with a many-to-many relationship. -When removing different pairs of objects from :class:`ManyToManyFields -`, use -:meth:`~django.db.models.query.QuerySet.delete` on a -:class:`~django.db.models.Q` expression with multiple -:attr:`~django.db.models.ManyToManyField.through` model instances to reduce -the number of SQL queries. For example:: +When removing multiple many-to-many relationships involving several instances +of the related models, use the :meth:`~django.db.models.query.QuerySet.delete` +method on a filtered queryset of the field's +:attr:`~django.db.models.ManyToManyField.through` model. By combining multiple +conditions with :ref:`q-objects`, you can delete several relationships in a +single query. For example:: from django.db.models import Q diff --git a/docs/topics/db/queries.txt b/docs/topics/db/queries.txt index ed1d3ea9ed..f6b8717f58 100644 --- a/docs/topics/db/queries.txt +++ b/docs/topics/db/queries.txt @@ -541,9 +541,10 @@ is ``'Beatles Blog'``: This spanning can be as deep as you'd like. -It works backwards, too. While it :attr:`can be customized -<.ForeignKey.related_query_name>`, by default you refer to a "reverse" -relationship in a lookup using the lowercase name of the model. +It works backwards, too. While it can be customized by setting +:class:`~django.db.models.ForeignKey.related_query_name`, by default you +refer to a "reverse" relationship in a lookup using the lowercase name of the +model. This example retrieves all ``Blog`` objects which have at least one ``Entry`` whose ``headline`` contains ``'Lennon'``: @@ -692,10 +693,10 @@ In the examples given so far, we have constructed filters that compare the value of a model field with a constant. But what if you want to compare the value of a model field with another field on the same model? -Django provides :class:`F expressions ` to allow such -comparisons. Instances of ``F()`` act as a reference to a model field within a -query. These references can then be used in query filters to compare the values -of two different fields on the same model instance. +Django provides :ref:`f-expressions` to allow such comparisons. Instances of +``F()`` act as a reference to a model field within a query. These references +can then be used in query filters to compare the values of two different fields +on the same model instance. For example, to find a list of all blog entries that have had more comments than pingbacks, we construct an ``F()`` object to reference the pingback count, @@ -1370,12 +1371,11 @@ Complex lookups with ``Q`` objects Keyword argument queries -- in :meth:`~django.db.models.query.QuerySet.filter`, etc. -- are "AND"ed together. If you need to execute more complex queries (for -example, queries with ``OR`` statements), you can use -:class:`Q objects `. +example, queries with ``OR`` statements), you can use :ref:`q-objects`. -A :class:`Q object ` (``django.db.models.Q``) is an object -used to encapsulate a collection of keyword arguments. These keyword arguments -are specified as in "Field lookups" above. +A :ref:`Q object ` (``django.db.models.Q``) is an object used to +encapsulate a collection of keyword arguments. These keyword arguments are +specified as in "Field lookups" above. For example, this ``Q`` object encapsulates a single ``LIKE`` query:: @@ -1659,10 +1659,10 @@ them and call :meth:`~django.db.models.Model.save`:: for item in my_queryset: item.save() -Calls to update can also use :class:`F expressions ` to -update one field based on the value of another field in the model. This is -especially useful for incrementing counters based upon their current value. For -example, to increment the pingback count for every entry in the blog: +Calls to update can also use :ref:`f-expressions` to update one field based on +the value of another field in the model. This is especially useful for +incrementing counters based upon their current value. For example, to increment +the pingback count for every entry in the blog: .. code-block:: pycon diff --git a/docs/topics/forms/formsets.txt b/docs/topics/forms/formsets.txt index b3ea14dc27..28597464a3 100644 --- a/docs/topics/forms/formsets.txt +++ b/docs/topics/forms/formsets.txt @@ -50,8 +50,9 @@ following example will create a formset class to display two blank forms: Formsets can be iterated and indexed, accessing forms in the order they were created. You can reorder the forms by overriding the default -:meth:`iteration ` and -:meth:`indexing ` behavior if needed. +:meth:`~object.__iter__` and :meth:`~object.__getitem__` methods if needed. +(For more information on implementing these methods, see the +:term:`Python documentation on sequences `.) .. _formsets-initial-data: diff --git a/docs/topics/forms/modelforms.txt b/docs/topics/forms/modelforms.txt index a673a179f6..c46412654c 100644 --- a/docs/topics/forms/modelforms.txt +++ b/docs/topics/forms/modelforms.txt @@ -299,14 +299,14 @@ for more information on the model's ``clean()`` hook. Considerations regarding model's ``error_messages`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Error messages defined at the -:attr:`form field ` level or at the +Error messages defined at the form field level ( +:attr:`django.forms.Field.error_messages`) or at the :ref:`form Meta ` level always take -precedence over the error messages defined at the -:attr:`model field ` level. +precedence over the error messages defined at the model field level +(:attr:`django.db.models.Field.error_messages`). -Error messages defined on :attr:`model fields -` are only used when the +Error messages defined on model fields +(:attr:`django.db.models.Field.error_messages`) are only used when the ``ValidationError`` is raised during the :ref:`model validation ` step and no corresponding error messages are defined at the form level. diff --git a/docs/topics/http/shortcuts.txt b/docs/topics/http/shortcuts.txt index dfab9a5f5b..300cf2041c 100644 --- a/docs/topics/http/shortcuts.txt +++ b/docs/topics/http/shortcuts.txt @@ -206,7 +206,7 @@ Arguments the object. ``*args`` - :class:`Q objects `. + :ref:`q-objects`. ``**kwargs`` Lookup parameters, which should be in the format accepted by ``get()`` and @@ -286,7 +286,7 @@ Arguments list. ``*args`` - :class:`Q objects `. + :ref:`q-objects`. ``**kwargs`` Lookup parameters, which should be in the format accepted by ``get()`` and diff --git a/docs/topics/i18n/translation.txt b/docs/topics/i18n/translation.txt index 560aa476a1..1243789ec7 100644 --- a/docs/topics/i18n/translation.txt +++ b/docs/topics/i18n/translation.txt @@ -1043,7 +1043,7 @@ The ``JavaScriptCatalog`` view .. attribute:: packages - A list of :attr:`application names ` among + A list of application names (:attr:`.AppConfig.name`) among the installed applications. Those apps should contain a ``locale`` directory. All those catalogs plus all catalogs found in :setting:`LOCALE_PATHS` (which are always included) are merged into one diff --git a/docs/topics/testing/tools.txt b/docs/topics/testing/tools.txt index b2b89556bd..eda1849334 100644 --- a/docs/topics/testing/tools.txt +++ b/docs/topics/testing/tools.txt @@ -801,26 +801,21 @@ A subclass of :class:`unittest.TestCase` that adds this functionality: * Some useful assertions like: - * Checking that a callable :meth:`raises a certain exception - `. - * Checking that a callable :meth:`triggers a certain warning - `. - * Testing form field :meth:`rendering and error treatment - `. - * Testing :meth:`HTML responses for the presence/lack of a given fragment - `. - * Verifying that a template :meth:`has/hasn't been used to generate a given - response content `. - * Verifying that two :meth:`URLs ` are equal. - * Verifying an HTTP :meth:`redirect ` is - performed by the app. - * Robustly testing two :meth:`HTML fragments - ` for equality/inequality or - :meth:`containment `. - * Robustly testing two :meth:`XML fragments ` - for equality/inequality. - * Robustly testing two :meth:`JSON fragments - ` for equality. + =========================================== ====================================== + Assertion What it checks + =========================================== ====================================== + :meth:`~SimpleTestCase.assertRaisesMessage` That a callable raises a certain exception + :meth:`~SimpleTestCase.assertWarnsMessage` That a callable triggers a certain warning + :meth:`~SimpleTestCase.assertFieldOutput` Form field rendering and error output + :meth:`~SimpleTestCase.assertContains` Presence or absence of HTML fragments + :meth:`~SimpleTestCase.assertTemplateUsed` Template usage in a response + :meth:`~SimpleTestCase.assertURLEqual` That two URLs are equal + :meth:`~SimpleTestCase.assertRedirects` That an HTTP redirect occurred + :meth:`~SimpleTestCase.assertHTMLEqual` HTML fragment equality + :meth:`~SimpleTestCase.assertInHTML` HTML fragment containment + :meth:`~SimpleTestCase.assertXMLEqual` XML fragment equality or inequality + :meth:`~SimpleTestCase.assertJSONEqual` JSON fragment equality + =========================================== ====================================== * The ability to run tests with :ref:`modified settings `. * Using the :attr:`~SimpleTestCase.client` :class:`~django.test.Client`. @@ -1688,7 +1683,7 @@ your test suite. .. method:: SimpleTestCase.assertContains(response, text, count=None, status_code=200, msg_prefix='', html=False) - Asserts that a :class:`response ` produced the + Asserts that an :class:`~django.http.HttpResponse` produced the given :attr:`~django.http.HttpResponse.status_code` and that ``text`` appears in its :attr:`~django.http.HttpResponse.content`. If ``count`` is provided, ``text`` must occur exactly ``count`` times in the response. @@ -1701,7 +1696,7 @@ your test suite. .. method:: SimpleTestCase.assertNotContains(response, text, status_code=200, msg_prefix='', html=False) - Asserts that a :class:`response ` produced the + Asserts that an :class:`~django.http.HttpResponse` produced the given :attr:`~django.http.HttpResponse.status_code` and that ``text`` does *not* appear in its :attr:`~django.http.HttpResponse.content`. @@ -1716,8 +1711,8 @@ your test suite. Asserts that the template with the given name was used in rendering the response. - ``response`` must be a response instance returned by the - :class:`test client `. + ``response`` must be a :class:`~django.test.Response` instance returned by + the test client. ``template_name`` should be a string such as ``'admin/index.html'``. @@ -1749,9 +1744,10 @@ your test suite. .. method:: SimpleTestCase.assertRedirects(response, expected_url, status_code=302, target_status_code=200, msg_prefix='', fetch_redirect_response=True) - Asserts that the :class:`response ` returned a - :attr:`~django.http.HttpResponse.status_code` redirect status, redirected - to ``expected_url`` (including any ``GET`` data), and that the final page + Asserts that the :class:`~django.http.HttpResponse` returned a response + with a redirect status (based on its + :attr:`~django.http.HttpResponse.status_code`), redirected to + ``expected_url`` (including any ``GET`` data), and that the final response was received with ``target_status_code``. If your request used the ``follow`` argument, the ``expected_url`` and From ab108bf94dfc06c311d7dc81866b848fe5b5ee6c Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Wed, 24 Sep 2025 15:29:09 -0400 Subject: [PATCH 108/116] Added stub release notes and release date for 5.2.8, 5.1.14, and 4.2.26. --- docs/releases/4.2.26.txt | 10 ++++++++++ docs/releases/5.1.14.txt | 10 ++++++++++ docs/releases/5.2.8.txt | 7 ++++--- docs/releases/index.txt | 2 ++ 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 docs/releases/4.2.26.txt create mode 100644 docs/releases/5.1.14.txt diff --git a/docs/releases/4.2.26.txt b/docs/releases/4.2.26.txt new file mode 100644 index 0000000000..e0db257c04 --- /dev/null +++ b/docs/releases/4.2.26.txt @@ -0,0 +1,10 @@ +=========================== +Django 4.2.26 release notes +=========================== + +*November 5, 2025* + +Django 4.2.26 fixes one security issue with severity "high" and one security +issue with severity "moderate" in 4.2.25. + +... diff --git a/docs/releases/5.1.14.txt b/docs/releases/5.1.14.txt new file mode 100644 index 0000000000..79a7a260e3 --- /dev/null +++ b/docs/releases/5.1.14.txt @@ -0,0 +1,10 @@ +=========================== +Django 5.1.14 release notes +=========================== + +*November 5, 2025* + +Django 5.1.14 fixes one security issue with severity "high" and one security +issue with severity "moderate" in 5.1.13. + +... diff --git a/docs/releases/5.2.8.txt b/docs/releases/5.2.8.txt index 4151012387..a75db9cbc8 100644 --- a/docs/releases/5.2.8.txt +++ b/docs/releases/5.2.8.txt @@ -2,10 +2,11 @@ Django 5.2.8 release notes ========================== -*Expected November 5, 2025* +*November 5, 2025* -Django 5.2.8 fixes several bugs in 5.2.7 and adds compatibility with Python -3.14. +Django 5.2.8 fixes one security issue with severity "high", one security issue +with severity "moderate", and several bugs in 5.2.7. It also adds compatibility +with Python 3.14. Bugfixes ======== diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 22c9710db3..18605ac878 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -54,6 +54,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 5.1.14 5.1.13 5.1.12 5.1.11 @@ -96,6 +97,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 4.2.26 4.2.25 4.2.24 4.2.23 From adc25a9a6696f7e2ab6181aabce3a23e027d6703 Mon Sep 17 00:00:00 2001 From: Clifford Gama Date: Thu, 7 Aug 2025 17:26:15 +0200 Subject: [PATCH 109/116] Fixed #35381 -- Added JSONNull() expression. Thanks Jacob Walls for the review. --- django/db/models/__init__.py | 3 +- django/db/models/fields/json.py | 23 ++++- docs/ref/models/expressions.txt | 12 +++ docs/releases/6.1.txt | 6 ++ docs/topics/db/queries.txt | 39 +++++++-- tests/model_fields/models.py | 16 ++++ tests/model_fields/test_jsonfield.py | 124 +++++++++++++++++++++++++++ 7 files changed, 213 insertions(+), 10 deletions(-) diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py index 757e098317..65123c3e85 100644 --- a/django/db/models/__init__.py +++ b/django/db/models/__init__.py @@ -45,7 +45,7 @@ from django.db.models.fields import __all__ as fields_all from django.db.models.fields.composite import CompositePrimaryKey from django.db.models.fields.files import FileField, ImageField from django.db.models.fields.generated import GeneratedField -from django.db.models.fields.json import JSONField +from django.db.models.fields.json import JSONField, JSONNull from django.db.models.fields.proxy import OrderWrt from django.db.models.indexes import * # NOQA from django.db.models.indexes import __all__ as indexes_all @@ -97,6 +97,7 @@ __all__ += [ "ExpressionWrapper", "F", "Func", + "JSONNull", "OrderBy", "OuterRef", "RowRange", diff --git a/django/db/models/fields/json.py b/django/db/models/fields/json.py index af5ec4c8b0..16be6846ff 100644 --- a/django/db/models/fields/json.py +++ b/django/db/models/fields/json.py @@ -148,6 +148,27 @@ class JSONField(CheckFieldDefaultMixin, Field): ) +class JSONNull(expressions.Value): + """Represent JSON `null` primitive.""" + + def __init__(self): + super().__init__(None, output_field=JSONField()) + + def __repr__(self): + return f"{self.__class__.__name__}()" + + def as_sql(self, compiler, connection): + value = self.output_field.get_db_prep_value(self.value, connection) + if value is None: + value = "null" + return "%s", (value,) + + def as_mysql(self, compiler, connection): + sql, params = self.as_sql(compiler, connection) + sql = "JSON_EXTRACT(%s, '$')" + return sql, params + + class DataContains(FieldGetDbPrepValueMixin, PostgresOperatorLookup): lookup_name = "contains" postgres_operator = "@>" @@ -318,7 +339,7 @@ class JSONExact(lookups.Exact): # Treat None lookup values as null. if rhs == "%s" and (*rhs_params,) == (None,): rhs_params = ("null",) - if connection.vendor == "mysql": + if connection.vendor == "mysql" and not isinstance(self.rhs, JSONNull): func = ["JSON_EXTRACT(%s, '$')"] * len(rhs_params) rhs %= tuple(func) return rhs, rhs_params diff --git a/docs/ref/models/expressions.txt b/docs/ref/models/expressions.txt index fa5b4b9540..037d7520a3 100644 --- a/docs/ref/models/expressions.txt +++ b/docs/ref/models/expressions.txt @@ -575,6 +575,18 @@ available on other expressions. ``ExpressionWrapper`` is necessary when using arithmetic on ``F()`` expressions with different types as described in :ref:`using-f-with-annotations`. +``JSONNull()`` expression +------------------------- + +.. versionadded:: 6.1 + +.. class:: JSONNull() + +Specialized expression to represent JSON scalar ``null`` on a +:class:`~django.db.models.JSONField`. + +See :ref:`storing-and-querying-for-none` for usage examples. + Conditional expressions ----------------------- diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 670026077a..bb8f686f84 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -238,6 +238,12 @@ Models * :meth:`.QuerySet.in_bulk` now supports chaining after :meth:`.QuerySet.values` and :meth:`.QuerySet.values_list`. +* The new :class:`~django.db.models.JSONNull` expression provides an explicit + way to represent the JSON scalar ``null``. It can be used when saving a + top-level :class:`~django.db.models.JSONField` value, or querying for + top-level or nested JSON ``null`` values. See + :ref:`storing-and-querying-for-none` for usage examples and some caveats. + Pagination ~~~~~~~~~~ diff --git a/docs/topics/db/queries.txt b/docs/topics/db/queries.txt index f6b8717f58..788a418e4f 100644 --- a/docs/topics/db/queries.txt +++ b/docs/topics/db/queries.txt @@ -1048,13 +1048,15 @@ the following example model:: def __str__(self): return self.name +.. _storing-and-querying-for-none: + Storing and querying for ``None`` --------------------------------- As with other fields, storing ``None`` as the field's value will store it as SQL ``NULL``. While not recommended, it is possible to store JSON scalar -``null`` instead of SQL ``NULL`` by using :class:`Value(None, JSONField()) -`. +``null`` instead of SQL ``NULL`` by using the :class:`JSONNull() +` expression. Whichever of the values is stored, when retrieved from the database, the Python representation of the JSON scalar ``null`` is the same as SQL ``NULL``, i.e. @@ -1064,18 +1066,21 @@ This only applies to ``None`` as the top-level value of the field. If ``None`` is inside a :class:`list` or :class:`dict`, it will always be interpreted as JSON ``null``. -When querying, ``None`` value will always be interpreted as JSON ``null``. To -query for SQL ``NULL``, use :lookup:`isnull`: +When querying, :lookup:`isnull=True ` is used to match SQL ``NULL``, +while exact-matching ``JSONNull()`` is used to match JSON ``null``. + +.. versionchanged:: 6.1 + + ``JSONNull()`` expression was added. .. code-block:: pycon + >>> from django.db.models import JSONNull >>> Dog.objects.create(name="Max", data=None) # SQL NULL. - >>> Dog.objects.create(name="Archie", data=Value(None, JSONField())) # JSON null. + >>> Dog.objects.create(name="Archie", data=JSONNull()) # JSON null. - >>> Dog.objects.filter(data=None) - ]> - >>> Dog.objects.filter(data=Value(None, JSONField())) + >>> Dog.objects.filter(data=JSONNull()) ]> >>> Dog.objects.filter(data__isnull=True) ]> @@ -1091,6 +1096,14 @@ Unless you are sure you wish to work with SQL ``NULL`` values, consider setting Storing JSON scalar ``null`` does not violate :attr:`null=False `. +.. admonition:: Storing JSON ``null`` inside JSON data + + While :class:`JSONNull() ` can be used in + :lookup:`jsonfield.key` exact lookups, it cannot be stored inside + :class:`dict` or :class:`list` instances meant to be saved in a + ``JSONField``, unless a custom encoder is used. If you don't want to use + a custom encoder, use ``None`` instead. + .. fieldlookup:: jsonfield.key .. _key-index-and-path-transforms: @@ -1122,6 +1135,16 @@ To query based on a given dictionary key, use that key as the lookup name: >>> Dog.objects.filter(data__breed="collie") ]> +To query a key for JSON ``null``, ``None`` or :class:`JSONNull() +` can be used. + +.. code-block:: pycon + + >>> Dog.objects.filter(data__owner=None) + + >>> Dog.objects.filter(data__owner=JSONNull()) + + Multiple keys can be chained together to form a path lookup: .. code-block:: pycon diff --git a/tests/model_fields/models.py b/tests/model_fields/models.py index ba8d4fa6b0..816e5a16ba 100644 --- a/tests/model_fields/models.py +++ b/tests/model_fields/models.py @@ -403,6 +403,13 @@ class CustomJSONDecoder(json.JSONDecoder): return dct +class JSONNullCustomEncoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, models.JSONNull): + return None + return super().default(o) + + class JSONModel(models.Model): value = models.JSONField() @@ -422,6 +429,15 @@ class NullableJSONModel(models.Model): required_db_features = {"supports_json_field"} +class JSONNullDefaultModel(models.Model): + value = models.JSONField( + db_default=models.JSONNull(), encoder=JSONNullCustomEncoder + ) + + class Meta: + required_db_features = {"supports_json_field"} + + class RelatedJSONModel(models.Model): value = models.JSONField() json_model = models.ForeignKey(NullableJSONModel, models.CASCADE) diff --git a/tests/model_fields/test_jsonfield.py b/tests/model_fields/test_jsonfield.py index b16499d198..fd2a880f99 100644 --- a/tests/model_fields/test_jsonfield.py +++ b/tests/model_fields/test_jsonfield.py @@ -16,16 +16,20 @@ from django.db import ( transaction, ) from django.db.models import ( + Case, + CheckConstraint, Count, ExpressionWrapper, F, IntegerField, JSONField, + JSONNull, OuterRef, Q, Subquery, Transform, Value, + When, ) from django.db.models.expressions import RawSQL from django.db.models.fields.json import ( @@ -44,6 +48,7 @@ from .models import ( CustomJSONDecoder, CustomSerializationJSONModel, JSONModel, + JSONNullDefaultModel, NullableJSONModel, RelatedJSONModel, ) @@ -1241,3 +1246,122 @@ class TestQuerying(TestCase): data__foo="bar" ) self.assertQuerySetEqual(qs, all_objects) + + +@skipUnlessDBFeature("supports_primitives_in_json_field") +class JSONNullTests(TestCase): + def test_repr(self): + self.assertEqual(repr(JSONNull()), "JSONNull()") + + def test_save_load(self): + obj = JSONModel(value=JSONNull()) + obj.save() + self.assertIsNone(obj.value) + + def test_create(self): + obj = JSONModel.objects.create(value=JSONNull()) + self.assertIsNone(obj.value) + + def test_update(self): + obj = JSONModel.objects.create(value={"key": "value"}) + JSONModel.objects.update(value=JSONNull()) + obj.refresh_from_db() + self.assertIsNone(obj.value) + + def test_filter(self): + json_null = NullableJSONModel.objects.create(value=JSONNull()) + sql_null = NullableJSONModel.objects.create(value=None) + self.assertSequenceEqual( + [json_null], NullableJSONModel.objects.filter(value=JSONNull()) + ) + self.assertSequenceEqual( + NullableJSONModel.objects.filter(value__isnull=True), [sql_null] + ) + + def test_bulk_update(self): + obj1 = NullableJSONModel.objects.create(value={"k": "1st"}) + obj2 = NullableJSONModel.objects.create(value={"k": "2nd"}) + obj1.value = JSONNull() + obj2.value = JSONNull() + NullableJSONModel.objects.bulk_update([obj1, obj2], fields=["value"]) + self.assertSequenceEqual( + NullableJSONModel.objects.filter(value=JSONNull()), + [obj1, obj2], + ) + + def test_case_expression_with_jsonnull_then(self): + obj = JSONModel.objects.create(value={"key": "value"}) + JSONModel.objects.filter(pk=obj.pk).update( + value=Case( + When(value={"key": "value"}, then=JSONNull()), + ) + ) + obj.refresh_from_db() + self.assertIsNone(obj.value) + + def test_case_expr_with_jsonnull_condition(self): + obj = NullableJSONModel.objects.create(value=JSONNull()) + NullableJSONModel.objects.filter(pk=obj.pk).update( + value=Case( + When( + value=JSONNull(), + then=Value({"key": "replaced"}, output_field=JSONField()), + ) + ), + ) + obj.refresh_from_db() + self.assertEqual(obj.value, {"key": "replaced"}) + + def test_key_transform_exact_filter(self): + obj = NullableJSONModel.objects.create(value={"key": None}) + self.assertSequenceEqual( + NullableJSONModel.objects.filter(value__key=JSONNull()), + [obj], + ) + self.assertSequenceEqual( + NullableJSONModel.objects.filter(value__key=None), [obj] + ) + + def test_index_lookup(self): + obj = NullableJSONModel.objects.create(value=["a", "b", None, 3]) + self.assertSequenceEqual( + NullableJSONModel.objects.filter(value__2=JSONNull()), [obj] + ) + self.assertSequenceEqual(NullableJSONModel.objects.filter(value__2=None), [obj]) + + @skipUnlessDBFeature("supports_table_check_constraints") + def test_constraint_validation(self): + constraint = CheckConstraint( + condition=~Q(value=JSONNull()), name="check_not_json_null" + ) + constraint.validate(NullableJSONModel, NullableJSONModel(value={"key": None})) + msg = f"Constraint “{constraint.name}” is violated." + with self.assertRaisesMessage(ValidationError, msg): + constraint.validate(NullableJSONModel, NullableJSONModel(value=JSONNull())) + + @skipUnlessDBFeature("supports_table_check_constraints") + def test_constraint_validation_key_transform(self): + constraint = CheckConstraint( + condition=Q(value__has_key="name") & ~Q(value__name=JSONNull()), + name="check_value_name_not_json_null", + ) + constraint.validate( + NullableJSONModel, NullableJSONModel(value={"name": "Django"}) + ) + msg = f"Constraint “{constraint.name}” is violated." + with self.assertRaisesMessage(ValidationError, msg): + constraint.validate( + NullableJSONModel, NullableJSONModel(value={"name": None}) + ) + + def test_default(self): + obj = JSONNullDefaultModel.objects.create() + self.assertIsNone(obj.value) + + def test_custom_jsonnull_encoder(self): + obj = JSONNullDefaultModel.objects.create( + value={"name": JSONNull(), "array": [1, JSONNull()]} + ) + obj.refresh_from_db() + self.assertIsNone(obj.value["name"]) + self.assertEqual(obj.value["array"], [1, None]) From be7f68422d4c6ae568a17f1fa91aac67d284df82 Mon Sep 17 00:00:00 2001 From: Clifford Gama Date: Tue, 21 Oct 2025 11:34:58 +0200 Subject: [PATCH 110/116] Refs #35381 -- Delegated ArrayField element prepping to base_field.get_db_prep_save. Previously, ArrayField always used base_field.get_db_prep_value when saving, which could differ from how base_field prepares data for save. This change overrides ArrayField.get_db_prep_save to delegate to the base_field's get_db_prep_save, ensuring elements like None in JSONField arrays are saved correctly as SQL NULL instead of JSON null. --- django/contrib/postgres/fields/array.py | 5 +++++ docs/releases/6.1.txt | 9 +++++++++ tests/postgres_tests/models.py | 2 +- tests/postgres_tests/test_array.py | 27 +++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/django/contrib/postgres/fields/array.py b/django/contrib/postgres/fields/array.py index 078428416c..a76598a9bf 100644 --- a/django/contrib/postgres/fields/array.py +++ b/django/contrib/postgres/fields/array.py @@ -135,6 +135,11 @@ class ArrayField(CheckPostgresInstalledMixin, CheckFieldDefaultMixin, Field): ] return value + def get_db_prep_save(self, value, connection): + if isinstance(value, (list, tuple)): + return [self.base_field.get_db_prep_save(i, connection) for i in value] + return value + def deconstruct(self): name, path, args, kwargs = super().deconstruct() if path == "django.contrib.postgres.fields.array.ArrayField": diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index bb8f686f84..412ec692e3 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -319,6 +319,15 @@ backends. * Support for PostGIS 3.1 is removed. +:mod:`django.contrib.postgres` +------------------------------ + +* Top-level elements set to ``None`` in an + :class:`~django.contrib.postgres.fields.ArrayField` with a + :class:`~django.db.models.JSONField` base field are now saved as SQL ``NULL`` + instead of the JSON ``null`` primitive. This matches the behavior of a + standalone :class:`~django.db.models.JSONField` when storing ``None`` values. + Dropped support for PostgreSQL 14 --------------------------------- diff --git a/tests/postgres_tests/models.py b/tests/postgres_tests/models.py index f07f4492b8..6a3d25a6af 100644 --- a/tests/postgres_tests/models.py +++ b/tests/postgres_tests/models.py @@ -79,7 +79,7 @@ class OtherTypesArrayModel(PostgreSQLModel): models.DecimalField(max_digits=5, decimal_places=2), default=list ) tags = ArrayField(TagField(), blank=True, null=True) - json = ArrayField(models.JSONField(default=dict), default=list) + json = ArrayField(models.JSONField(default=dict), default=list, null=True) int_ranges = ArrayField(IntegerRangeField(), blank=True, null=True) bigint_ranges = ArrayField(BigIntegerRangeField(), blank=True, null=True) diff --git a/tests/postgres_tests/test_array.py b/tests/postgres_tests/test_array.py index 392b8f946c..e65009ad83 100644 --- a/tests/postgres_tests/test_array.py +++ b/tests/postgres_tests/test_array.py @@ -10,6 +10,7 @@ from django.core import checks, exceptions, serializers, validators from django.core.exceptions import FieldError from django.core.management import call_command from django.db import IntegrityError, connection, models +from django.db.models import JSONNull from django.db.models.expressions import Exists, F, OuterRef, RawSQL, Value from django.db.models.functions import Cast, JSONObject, Upper from django.test import TransactionTestCase, override_settings, skipUnlessDBFeature @@ -1577,3 +1578,29 @@ class TestAdminUtils(PostgreSQLTestCase): self.empty_value, ) self.assertEqual(display_value, self.empty_value) + + +class TestJSONFieldQuerying(PostgreSQLTestCase): + def test_saving_and_querying_for_sql_null(self): + obj = OtherTypesArrayModel.objects.create(json=[None, None]) + self.assertSequenceEqual( + OtherTypesArrayModel.objects.filter(json__1__isnull=True), [obj] + ) + + def test_saving_and_querying_for_json_null(self): + obj = OtherTypesArrayModel.objects.create(json=[JSONNull(), JSONNull()]) + self.assertSequenceEqual( + OtherTypesArrayModel.objects.filter(json__1=JSONNull()), [obj] + ) + self.assertSequenceEqual( + OtherTypesArrayModel.objects.filter(json__1__isnull=True), [] + ) + + def test_saving_and_querying_for_nested_json_nulls(self): + obj = OtherTypesArrayModel.objects.create(json=[[None, 1], [None, 2]]) + self.assertSequenceEqual( + OtherTypesArrayModel.objects.filter(json__1__0=None), [obj] + ) + self.assertSequenceEqual( + OtherTypesArrayModel.objects.filter(json__1__0__isnull=True), [] + ) From 348ca845385beaddc7c862ff8ec369f041a5088d Mon Sep 17 00:00:00 2001 From: Clifford Gama Date: Fri, 24 Oct 2025 23:38:52 +0200 Subject: [PATCH 111/116] Refs #35381 -- Deprecated using None in JSONExact rhs to mean JSON null. Key and index lookups are exempt from the deprecation. Co-authored-by: Jacob Walls --- django/db/models/fields/json.py | 20 ++++++++++++ docs/releases/6.1.txt | 7 +++++ docs/topics/db/queries.txt | 14 +++++++++ tests/model_fields/test_jsonfield.py | 47 +++++++++++++++++++++++++++- tests/postgres_tests/test_array.py | 12 +++++++ 5 files changed, 99 insertions(+), 1 deletion(-) diff --git a/django/db/models/fields/json.py b/django/db/models/fields/json.py index 16be6846ff..819c87119a 100644 --- a/django/db/models/fields/json.py +++ b/django/db/models/fields/json.py @@ -1,4 +1,5 @@ import json +import warnings from django import forms from django.core import checks, exceptions @@ -11,6 +12,7 @@ from django.db.models.lookups import ( PostgresOperatorLookup, Transform, ) +from django.utils.deprecation import RemovedInDjango70Warning, django_file_prefixes from django.utils.translation import gettext_lazy as _ from . import Field @@ -332,10 +334,24 @@ class CaseInsensitiveMixin: class JSONExact(lookups.Exact): + # RemovedInDjango70Warning: When the deprecation period is over, remove + # the following line. can_use_none_as_rhs = True def process_rhs(self, compiler, connection): + if self.rhs is None and not isinstance(self.lhs, KeyTransform): + warnings.warn( + "Using None as the right-hand side of an exact lookup on JSONField to " + "mean JSON scalar 'null' is deprecated. Use JSONNull() instead (or use " + "the __isnull lookup if you meant SQL NULL).", + RemovedInDjango70Warning, + skip_file_prefixes=django_file_prefixes(), + ) + rhs, rhs_params = super().process_rhs(compiler, connection) + + # RemovedInDjango70Warning: When the deprecation period is over, remove + # The following if-block entirely. # Treat None lookup values as null. if rhs == "%s" and (*rhs_params,) == (None,): rhs_params = ("null",) @@ -547,6 +563,10 @@ class KeyTransformIn(lookups.In): class KeyTransformExact(JSONExact): + # RemovedInDjango70Warning: When deprecation period ends, uncomment the + # flag below. + # can_use_none_as_rhs = True + def process_rhs(self, compiler, connection): if isinstance(self.rhs, KeyTransform): return super(lookups.Exact, self).process_rhs(compiler, connection) diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 412ec692e3..dba26cca05 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -360,6 +360,13 @@ Miscellaneous is deprecated. Pass an explicit field name, like ``values_list("pk", flat=True)``. +* The use of ``None`` to represent a top-level JSON scalar ``null`` when + querying :class:`~django.db.models.JSONField` is now deprecated in favor of + the new :class:`~django.db.models.JSONNull` expression. At the end + of the deprecation period, ``None`` values compile to SQL ``IS NULL`` when + used as the top-level value. :lookup:`Key and index lookups ` + are unaffected by this deprecation. + Features removed in 6.1 ======================= diff --git a/docs/topics/db/queries.txt b/docs/topics/db/queries.txt index 788a418e4f..b3b6ec125d 100644 --- a/docs/topics/db/queries.txt +++ b/docs/topics/db/queries.txt @@ -1069,6 +1069,11 @@ as JSON ``null``. When querying, :lookup:`isnull=True ` is used to match SQL ``NULL``, while exact-matching ``JSONNull()`` is used to match JSON ``null``. +.. deprecated:: 6.1 + + Exact-matching ``None`` in a query to mean JSON ``null`` is deprecated. + After the deprecation period, it will be interpreted as SQL ``NULL``. + .. versionchanged:: 6.1 ``JSONNull()`` expression was added. @@ -1080,6 +1085,12 @@ while exact-matching ``JSONNull()`` is used to match JSON ``null``. >>> Dog.objects.create(name="Archie", data=JSONNull()) # JSON null. + >>> Dog.objects.filter(data=None) + ...: RemovedInDjango70Warning: Using None as the right-hand side of an + exact lookup on JSONField to mean JSON scalar 'null' is deprecated. Use + JSONNull() instead (or use the __isnull lookup if you meant SQL NULL). + ... + ]> >>> Dog.objects.filter(data=JSONNull()) ]> >>> Dog.objects.filter(data__isnull=True) @@ -1087,6 +1098,9 @@ while exact-matching ``JSONNull()`` is used to match JSON ``null``. >>> Dog.objects.filter(data__isnull=False) ]> +.. RemovedInDjango70Warning: Alter the example with the deprecation warning to: + ]>. + Unless you are sure you wish to work with SQL ``NULL`` values, consider setting ``null=False`` and providing a suitable default for empty values, such as ``default=dict``. diff --git a/tests/model_fields/test_jsonfield.py b/tests/model_fields/test_jsonfield.py index fd2a880f99..937b557794 100644 --- a/tests/model_fields/test_jsonfield.py +++ b/tests/model_fields/test_jsonfield.py @@ -41,8 +41,15 @@ from django.db.models.fields.json import ( KeyTransformTextLookupMixin, ) from django.db.models.functions import Cast -from django.test import SimpleTestCase, TestCase, skipIfDBFeature, skipUnlessDBFeature +from django.test import ( + SimpleTestCase, + TestCase, + ignore_warnings, + skipIfDBFeature, + skipUnlessDBFeature, +) from django.test.utils import CaptureQueriesContext +from django.utils.deprecation import RemovedInDjango70Warning from .models import ( CustomJSONDecoder, @@ -229,6 +236,8 @@ class TestSaveLoad(TestCase): self.assertIsNone(obj.value) @skipUnlessDBFeature("supports_primitives_in_json_field") + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) def test_json_null_different_from_sql_null(self): json_null = NullableJSONModel.objects.create(value=Value(None, JSONField())) NullableJSONModel.objects.update(value=Value(None, JSONField())) @@ -242,6 +251,9 @@ class TestSaveLoad(TestCase): ) self.assertSequenceEqual( NullableJSONModel.objects.filter(value=None), + # RemovedInDjango70Warning: When the deprecation ends, replace + # with: + # [sql_null], [json_null], ) self.assertSequenceEqual( @@ -1365,3 +1377,36 @@ class JSONNullTests(TestCase): obj.refresh_from_db() self.assertIsNone(obj.value["name"]) self.assertEqual(obj.value["array"], [1, None]) + + +# RemovedInDjango70Warning. +@skipUnlessDBFeature("supports_primitives_in_json_field") +class JSONExactNoneDeprecationTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.msg = ( + "Using None as the right-hand side of an exact lookup on JSONField to mean " + "JSON scalar 'null' is deprecated. Use JSONNull() instead (or use the " + "__isnull lookup if you meant SQL NULL)." + ) + cls.obj = NullableJSONModel.objects.create(value=JSONNull()) + + def test_filter(self): + with self.assertWarnsMessage(RemovedInDjango70Warning, self.msg): + self.assertSequenceEqual( + NullableJSONModel.objects.filter(value=None), [self.obj] + ) + + def test_annotation_q_filter(self): + qs = NullableJSONModel.objects.annotate( + has_empty_data=Q(value__isnull=True) | Q(value=None) + ).filter(has_empty_data=True) + with self.assertWarnsMessage(RemovedInDjango70Warning, self.msg): + self.assertSequenceEqual(qs, [self.obj]) + + def test_case_when(self): + qs = NullableJSONModel.objects.annotate( + has_json_null=Case(When(value=None, then=Value(True)), default=Value(False)) + ).filter(has_json_null=True) + with self.assertWarnsMessage(RemovedInDjango70Warning, self.msg): + self.assertSequenceEqual(qs, [self.obj]) diff --git a/tests/postgres_tests/test_array.py b/tests/postgres_tests/test_array.py index e65009ad83..f35211e8ed 100644 --- a/tests/postgres_tests/test_array.py +++ b/tests/postgres_tests/test_array.py @@ -16,6 +16,7 @@ from django.db.models.functions import Cast, JSONObject, Upper from django.test import TransactionTestCase, override_settings, skipUnlessDBFeature from django.test.utils import isolate_apps from django.utils import timezone +from django.utils.deprecation import RemovedInDjango70Warning from . import PostgreSQLSimpleTestCase, PostgreSQLTestCase, PostgreSQLWidgetTestCase from .models import ( @@ -1586,6 +1587,17 @@ class TestJSONFieldQuerying(PostgreSQLTestCase): self.assertSequenceEqual( OtherTypesArrayModel.objects.filter(json__1__isnull=True), [obj] ) + # RemovedInDjango70Warning. + msg = ( + "Using None as the right-hand side of an exact lookup on JSONField to mean " + "JSON scalar 'null' is deprecated. Use JSONNull() instead (or use the " + "__isnull lookup if you meant SQL NULL)." + ) + with self.assertWarnsMessage(RemovedInDjango70Warning, msg): + # RemovedInDjango70Warning: deindent, and replace [] with [obj]. + self.assertSequenceEqual( + OtherTypesArrayModel.objects.filter(json__1=None), [] + ) def test_saving_and_querying_for_json_null(self): obj = OtherTypesArrayModel.objects.create(json=[JSONNull(), JSONNull()]) From 7fc9db1c6a3a4865d85338f26812ce80f076ebec Mon Sep 17 00:00:00 2001 From: Clifford Gama Date: Thu, 23 Oct 2025 15:57:16 +0200 Subject: [PATCH 112/116] Refs #35381 -- Clarified key and index lookup handling of None in exact lookup docs. --- docs/ref/models/querysets.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index e494739bcd..9bfaea025d 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -3188,6 +3188,8 @@ As a convenience when no lookup type is provided (like in Exact match. If the value provided for comparison is ``None``, it will be interpreted as an SQL ``NULL`` (see :lookup:`isnull` for more details). +:lookup:`Key and index lookups ` are exceptions: they +interpret ``None`` as JSON ``null`` instead. Examples:: From 3939cd279569fde44f557d79f20bb5b1a02440af Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Thu, 30 Oct 2025 12:38:17 +0100 Subject: [PATCH 113/116] Refs #36680 -- Fixed admin_scripts tests crash when black is not installed. Regression in 6436ec321073bf0622af815e0af08f54c97f9b30. --- tests/admin_scripts/tests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/admin_scripts/tests.py b/tests/admin_scripts/tests.py index 9442822bd6..c01a5571dc 100644 --- a/tests/admin_scripts/tests.py +++ b/tests/admin_scripts/tests.py @@ -119,7 +119,8 @@ class AdminScriptTestCase(SimpleTestCase): path_component for path_component in os.environ.get("PATH", "").split(os.pathsep) for formatter_path in find_formatters().values() - if os.path.commonpath([path_component, formatter_path]) == os.sep + if formatter_path + and os.path.commonpath([path_component, formatter_path]) == os.sep ] ) From 340e4f832e1ea74a27770e38635bbc781979f2e0 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Mon, 27 Oct 2025 10:01:04 -0500 Subject: [PATCH 114/116] Added community package storage backends mention to docs. Co-authored-by: Jacob Walls --- docs/ref/files/storage.txt | 9 +++++++++ docs/ref/settings.txt | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/docs/ref/files/storage.txt b/docs/ref/files/storage.txt index 85a1e3b926..6ed510889e 100644 --- a/docs/ref/files/storage.txt +++ b/docs/ref/files/storage.txt @@ -257,3 +257,12 @@ The ``Storage`` class Returns the URL where the contents of the file referenced by ``name`` can be accessed. For storage systems that don't support access by URL this will raise ``NotImplementedError`` instead. + +.. admonition:: There are community-maintained solutions too! + + Django has a vibrant ecosystem. There are storage backends + highlighted on the `Community Ecosystem`_ page. The Django Packages + `Storage Backends grid`_ has even more options for you! + +.. _Community Ecosystem: https://www.djangoproject.com/community/ecosystem/#storage-and-static-files +.. _Storage Backends grid: https://djangopackages.org/grids/g/storage-backends/ diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 8ac7a194f6..c2cac541f7 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -2767,6 +2767,15 @@ backend definition in :setting:`STORAGES`. Defining this setting overrides the default value and is *not* merged with it. +.. admonition:: There are community maintained solutions too! + + Django has a vibrant ecosystem. There are storage backends + highlighted on the `Community Ecosystem`_ page. The Django Packages + `Storage Backends grid`_ has even more options for you! + +.. _Community Ecosystem: https://www.djangoproject.com/community/ecosystem/#storage-and-static-files +.. _Storage Backends grid: https://djangopackages.org/grids/g/storage-backends/ + .. setting:: TASKS ``TASKS`` From 601914722956cc41f1f2c53972d669ddee6ffc04 Mon Sep 17 00:00:00 2001 From: Patrick Rauscher Date: Thu, 30 Oct 2025 10:13:14 +0100 Subject: [PATCH 115/116] Fixed #36696 -- Fixed NameError when inspecting functions with deferred annotations. In Python 3.14, annotations are deferred by default, so we should not assume that the names in them have been imported unconditionally. --- django/utils/inspect.py | 16 +++++++++++++++- tests/utils_tests/test_inspect.py | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/django/utils/inspect.py b/django/utils/inspect.py index 31f3cf994b..ac5e82ca14 100644 --- a/django/utils/inspect.py +++ b/django/utils/inspect.py @@ -1,10 +1,24 @@ import functools import inspect +from django.utils.version import PY314 + +if PY314: + import annotationlib + @functools.lru_cache(maxsize=512) def _get_func_parameters(func, remove_first): - parameters = tuple(inspect.signature(func).parameters.values()) + # As the annotations are not used in any case, inspect the signature with + # FORWARDREF to leave any deferred annotations unevaluated. + if PY314: + signature = inspect.signature( + func, annotation_format=annotationlib.Format.FORWARDREF + ) + else: + signature = inspect.signature(func) + + parameters = tuple(signature.parameters.values()) if remove_first: parameters = parameters[1:] return parameters diff --git a/tests/utils_tests/test_inspect.py b/tests/utils_tests/test_inspect.py index 38ea35ecfb..f6e82e5808 100644 --- a/tests/utils_tests/test_inspect.py +++ b/tests/utils_tests/test_inspect.py @@ -1,8 +1,13 @@ import subprocess import unittest +from typing import TYPE_CHECKING from django.shortcuts import aget_object_or_404 from django.utils import inspect +from django.utils.version import PY314 + +if TYPE_CHECKING: + from django.utils.safestring import SafeString class Person: @@ -103,6 +108,16 @@ class TestInspectMethods(unittest.TestCase): self.assertIs(inspect.func_accepts_kwargs(Person.all_kinds), True) self.assertIs(inspect.func_accepts_kwargs(Person().just_args), False) + @unittest.skipUnless(PY314, "Deferred annotations are Python 3.14+ only") + def test_func_accepts_kwargs_deferred_annotations(self): + + def func_with_annotations(self, name: str, complex: SafeString) -> None: + pass + + # Inspection fails with deferred annotations with python 3.14+. Earlier + # Python versions trigger the NameError on module initialization. + self.assertIs(inspect.func_accepts_kwargs(func_with_annotations), False) + class IsModuleLevelFunctionTestCase(unittest.TestCase): @classmethod From 05ba1a9228128614fb3c475f1c4bdf0160f44dba Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Fri, 31 Oct 2025 14:33:27 +0100 Subject: [PATCH 116/116] Fixed #36661 -- Added introspection of database-level delete options. --- django/core/management/commands/inspectdb.py | 13 +++++- django/db/backends/base/introspection.py | 16 ++++++- django/db/backends/mysql/base.py | 1 + django/db/backends/mysql/introspection.py | 34 +++++++++----- django/db/backends/oracle/introspection.py | 17 +++++-- .../db/backends/postgresql/introspection.py | 24 ++++++++-- django/db/backends/sqlite3/introspection.py | 16 +++++-- docs/releases/6.1.txt | 5 ++ tests/inspectdb/models.py | 8 ++++ tests/inspectdb/tests.py | 21 +++++++++ tests/introspection/models.py | 15 ++++++ tests/introspection/tests.py | 46 +++++++++++++++++-- 12 files changed, 185 insertions(+), 31 deletions(-) diff --git a/django/core/management/commands/inspectdb.py b/django/core/management/commands/inspectdb.py index 8c271498c6..e44edcfe91 100644 --- a/django/core/management/commands/inspectdb.py +++ b/django/core/management/commands/inspectdb.py @@ -4,6 +4,7 @@ import re from django.core.management.base import BaseCommand, CommandError from django.db import DEFAULT_DB_ALIAS, connections from django.db.models.constants import LOOKUP_SEP +from django.db.models.deletion import DatabaseOnDelete class Command(BaseCommand): @@ -163,7 +164,9 @@ class Command(BaseCommand): extra_params["unique"] = True if is_relation: - ref_db_column, ref_db_table = relations[column_name] + ref_db_column, ref_db_table, db_on_delete = relations[ + column_name + ] if extra_params.pop("unique", False) or extra_params.get( "primary_key" ): @@ -191,6 +194,8 @@ class Command(BaseCommand): model_name.lower(), att_name, ) + if db_on_delete and isinstance(db_on_delete, DatabaseOnDelete): + extra_params["on_delete"] = f"models.{db_on_delete}" used_relations.add(rel_to) else: # Calling `get_field_type` to get the field type string @@ -227,8 +232,12 @@ class Command(BaseCommand): "" if "." in field_type else "models.", field_type, ) + on_delete_qualname = extra_params.pop("on_delete", None) if field_type.startswith(("ForeignKey(", "OneToOneField(")): - field_desc += ", models.DO_NOTHING" + if on_delete_qualname: + field_desc += f", {on_delete_qualname}" + else: + field_desc += ", models.DO_NOTHING" # Add comment. if connection.features.supports_comments and row.comment: diff --git a/django/db/backends/base/introspection.py b/django/db/backends/base/introspection.py index 3a62ab6327..b86f5b6124 100644 --- a/django/db/backends/base/introspection.py +++ b/django/db/backends/base/introspection.py @@ -1,5 +1,7 @@ from collections import namedtuple +from django.db.models import DB_CASCADE, DB_SET_DEFAULT, DB_SET_NULL, DO_NOTHING + # Structure returned by DatabaseIntrospection.get_table_list() TableInfo = namedtuple("TableInfo", ["name", "type"]) @@ -15,6 +17,13 @@ class BaseDatabaseIntrospection: """Encapsulate backend-specific introspection utilities.""" data_types_reverse = {} + on_delete_types = { + "CASCADE": DB_CASCADE, + "NO ACTION": DO_NOTHING, + "SET DEFAULT": DB_SET_DEFAULT, + "SET NULL": DB_SET_NULL, + # DB_RESTRICT - "RESTRICT" is not supported. + } def __init__(self, connection): self.connection = connection @@ -169,8 +178,11 @@ class BaseDatabaseIntrospection: def get_relations(self, cursor, table_name): """ - Return a dictionary of {field_name: (field_name_other_table, - other_table)} representing all foreign keys in the given table. + Return a dictionary of + { + field_name: (field_name_other_table, other_table, db_on_delete) + } + representing all foreign keys in the given table. """ raise NotImplementedError( "subclasses of BaseDatabaseIntrospection may require a " diff --git a/django/db/backends/mysql/base.py b/django/db/backends/mysql/base.py index d4b98971fa..514db70d23 100644 --- a/django/db/backends/mysql/base.py +++ b/django/db/backends/mysql/base.py @@ -334,6 +334,7 @@ class DatabaseWrapper(BaseDatabaseWrapper): for column_name, ( referenced_column_name, referenced_table_name, + _, ) in relations.items(): cursor.execute( """ diff --git a/django/db/backends/mysql/introspection.py b/django/db/backends/mysql/introspection.py index 24f773f009..59105b4e76 100644 --- a/django/db/backends/mysql/introspection.py +++ b/django/db/backends/mysql/introspection.py @@ -196,24 +196,36 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): def get_relations(self, cursor, table_name): """ - Return a dictionary of {field_name: (field_name_other_table, - other_table)} representing all foreign keys in the given table. + Return a dictionary of + { + field_name: (field_name_other_table, other_table, db_on_delete) + } + representing all foreign keys in the given table. """ cursor.execute( """ - SELECT column_name, referenced_column_name, referenced_table_name - FROM information_schema.key_column_usage - WHERE table_name = %s - AND table_schema = DATABASE() - AND referenced_table_schema = DATABASE() - AND referenced_table_name IS NOT NULL - AND referenced_column_name IS NOT NULL + SELECT + kcu.column_name, + kcu.referenced_column_name, + kcu.referenced_table_name, + rc.delete_rule + FROM + information_schema.key_column_usage kcu + JOIN + information_schema.referential_constraints rc + ON rc.constraint_name = kcu.constraint_name + AND rc.constraint_schema = kcu.constraint_schema + WHERE kcu.table_name = %s + AND kcu.table_schema = DATABASE() + AND kcu.referenced_table_schema = DATABASE() + AND kcu.referenced_table_name IS NOT NULL + AND kcu.referenced_column_name IS NOT NULL """, [table_name], ) return { - field_name: (other_field, other_table) - for field_name, other_field, other_table in cursor.fetchall() + field_name: (other_field, other_table, self.on_delete_types.get(on_delete)) + for field_name, other_field, other_table, on_delete in cursor.fetchall() } def get_storage_engine(self, cursor, table_name): diff --git a/django/db/backends/oracle/introspection.py b/django/db/backends/oracle/introspection.py index 12b9b9a097..6a0947f8ab 100644 --- a/django/db/backends/oracle/introspection.py +++ b/django/db/backends/oracle/introspection.py @@ -254,13 +254,16 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): def get_relations(self, cursor, table_name): """ - Return a dictionary of {field_name: (field_name_other_table, - other_table)} representing all foreign keys in the given table. + Return a dictionary of + { + field_name: (field_name_other_table, other_table, db_on_delete) + } + representing all foreign keys in the given table. """ table_name = table_name.upper() cursor.execute( """ - SELECT ca.column_name, cb.table_name, cb.column_name + SELECT ca.column_name, cb.table_name, cb.column_name, user_constraints.delete_rule FROM user_constraints, USER_CONS_COLUMNS ca, USER_CONS_COLUMNS cb WHERE user_constraints.table_name = %s AND user_constraints.constraint_name = ca.constraint_name AND @@ -273,8 +276,14 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): self.identifier_converter(field_name): ( self.identifier_converter(rel_field_name), self.identifier_converter(rel_table_name), + self.on_delete_types.get(on_delete), ) - for field_name, rel_table_name, rel_field_name in cursor.fetchall() + for ( + field_name, + rel_table_name, + rel_field_name, + on_delete, + ) in cursor.fetchall() } def get_primary_key_columns(self, cursor, table_name): diff --git a/django/db/backends/postgresql/introspection.py b/django/db/backends/postgresql/introspection.py index fc69e0a381..30edaf10da 100644 --- a/django/db/backends/postgresql/introspection.py +++ b/django/db/backends/postgresql/introspection.py @@ -3,7 +3,7 @@ from collections import namedtuple from django.db.backends.base.introspection import BaseDatabaseIntrospection from django.db.backends.base.introspection import FieldInfo as BaseFieldInfo from django.db.backends.base.introspection import TableInfo as BaseTableInfo -from django.db.models import Index +from django.db.models import DB_CASCADE, DB_SET_DEFAULT, DB_SET_NULL, DO_NOTHING, Index FieldInfo = namedtuple("FieldInfo", [*BaseFieldInfo._fields, "is_autofield", "comment"]) TableInfo = namedtuple("TableInfo", [*BaseTableInfo._fields, "comment"]) @@ -38,6 +38,14 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): ignored_tables = [] + on_delete_types = { + "a": DO_NOTHING, + "c": DB_CASCADE, + "d": DB_SET_DEFAULT, + "n": DB_SET_NULL, + # DB_RESTRICT - "r" is not supported. + } + def get_field_type(self, data_type, description): field_type = super().get_field_type(data_type, description) if description.is_autofield or ( @@ -154,12 +162,15 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): def get_relations(self, cursor, table_name): """ - Return a dictionary of {field_name: (field_name_other_table, - other_table)} representing all foreign keys in the given table. + Return a dictionary of + { + field_name: (field_name_other_table, other_table, db_on_delete) + } + representing all foreign keys in the given table. """ cursor.execute( """ - SELECT a1.attname, c2.relname, a2.attname + SELECT a1.attname, c2.relname, a2.attname, con.confdeltype FROM pg_constraint con LEFT JOIN pg_class c1 ON con.conrelid = c1.oid LEFT JOIN pg_class c2 ON con.confrelid = c2.oid @@ -175,7 +186,10 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): """, [table_name], ) - return {row[0]: (row[2], row[1]) for row in cursor.fetchall()} + return { + row[0]: (row[2], row[1], self.on_delete_types.get(row[3])) + for row in cursor.fetchall() + } def get_constraints(self, cursor, table_name): """ diff --git a/django/db/backends/sqlite3/introspection.py b/django/db/backends/sqlite3/introspection.py index 1404c71e1e..b90f126505 100644 --- a/django/db/backends/sqlite3/introspection.py +++ b/django/db/backends/sqlite3/introspection.py @@ -153,20 +153,27 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): def get_relations(self, cursor, table_name): """ - Return a dictionary of {column_name: (ref_column_name, ref_table_name)} + Return a dictionary of + {column_name: (ref_column_name, ref_table_name, db_on_delete)} representing all foreign keys in the given table. """ cursor.execute( "PRAGMA foreign_key_list(%s)" % self.connection.ops.quote_name(table_name) ) return { - column_name: (ref_column_name, ref_table_name) + column_name: ( + ref_column_name, + ref_table_name, + self.on_delete_types.get(on_delete), + ) for ( _, _, ref_table_name, column_name, ref_column_name, + _, + on_delete, *_, ) in cursor.fetchall() } @@ -407,7 +414,10 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): "check": False, "index": False, } - for index, (column_name, (ref_column_name, ref_table_name)) in relations + for index, ( + column_name, + (ref_column_name, ref_table_name, _), + ) in relations } ) return constraints diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index dba26cca05..036fee53cf 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -314,6 +314,11 @@ backends. database has native support for ``DurationField``, override this method to simply return the value. +* The ``DatabaseIntrospection.get_relations()`` should now return a dictionary + with 3-tuples containing (``field_name_other_table``, ``other_table``, + ``db_on_delete``) as values. ``db_on_delete`` is one of the database-level + delete options e.g. :attr:`~django.db.models.DB_CASCADE`. + :mod:`django.contrib.gis` ------------------------- diff --git a/tests/inspectdb/models.py b/tests/inspectdb/models.py index 515a6cd207..3d6388a7be 100644 --- a/tests/inspectdb/models.py +++ b/tests/inspectdb/models.py @@ -161,3 +161,11 @@ class CompositePKModel(models.Model): pk = models.CompositePrimaryKey("column_1", "column_2") column_1 = models.IntegerField() column_2 = models.IntegerField() + + +class DbOnDeleteModel(models.Model): + fk_do_nothing = models.ForeignKey(UniqueTogether, on_delete=models.DO_NOTHING) + fk_db_cascade = models.ForeignKey(ColumnTypes, on_delete=models.DB_CASCADE) + fk_set_null = models.ForeignKey( + DigitsInColumnName, on_delete=models.DB_SET_NULL, null=True + ) diff --git a/tests/inspectdb/tests.py b/tests/inspectdb/tests.py index c554488c10..9104671b4f 100644 --- a/tests/inspectdb/tests.py +++ b/tests/inspectdb/tests.py @@ -299,6 +299,27 @@ class InspectDBTestCase(TestCase): out.getvalue(), ) + @skipUnlessDBFeature("can_introspect_foreign_keys") + def test_foreign_key_db_on_delete(self): + out = StringIO() + call_command("inspectdb", "inspectdb_dbondeletemodel", stdout=out) + output = out.getvalue() + self.assertIn( + "fk_do_nothing = models.ForeignKey('InspectdbUniquetogether', " + "models.DO_NOTHING)", + output, + ) + self.assertIn( + "fk_db_cascade = models.ForeignKey('InspectdbColumntypes', " + "models.DB_CASCADE)", + output, + ) + self.assertIn( + "fk_set_null = models.ForeignKey('InspectdbDigitsincolumnname', " + "models.DB_SET_NULL, blank=True, null=True)", + output, + ) + def test_digits_column_name_introspection(self): """ Introspection of column names consist/start with digits (#16536/#17676) diff --git a/tests/introspection/models.py b/tests/introspection/models.py index c4a60ab182..6c94f5212c 100644 --- a/tests/introspection/models.py +++ b/tests/introspection/models.py @@ -110,3 +110,18 @@ class DbCommentModel(models.Model): class Meta: db_table_comment = "Custom table comment" required_db_features = {"supports_comments"} + + +class DbOnDeleteModel(models.Model): + fk_do_nothing = models.ForeignKey(Country, on_delete=models.DO_NOTHING) + fk_db_cascade = models.ForeignKey(City, on_delete=models.DB_CASCADE) + fk_set_null = models.ForeignKey(Reporter, on_delete=models.DB_SET_NULL, null=True) + + +class DbOnDeleteSetDefaultModel(models.Model): + fk_db_set_default = models.ForeignKey( + Country, on_delete=models.DB_SET_DEFAULT, db_default=models.Value(1) + ) + + class Meta: + required_db_features = {"supports_on_delete_db_default"} diff --git a/tests/introspection/tests.py b/tests/introspection/tests.py index 327e5cc8c6..1f7f22b2dc 100644 --- a/tests/introspection/tests.py +++ b/tests/introspection/tests.py @@ -1,5 +1,5 @@ from django.db import DatabaseError, connection -from django.db.models import Index +from django.db.models import DB_CASCADE, DB_SET_DEFAULT, DB_SET_NULL, DO_NOTHING, Index from django.test import TransactionTestCase, skipUnlessDBFeature from .models import ( @@ -10,6 +10,8 @@ from .models import ( Comment, Country, DbCommentModel, + DbOnDeleteModel, + DbOnDeleteSetDefaultModel, District, Reporter, UniqueConstraintConditionModel, @@ -219,10 +221,14 @@ class IntrospectionTests(TransactionTestCase): cursor, Article._meta.db_table ) - # That's {field_name: (field_name_other_table, other_table)} + if connection.vendor == "mysql" and connection.mysql_is_mariadb: + no_db_on_delete = None + else: + no_db_on_delete = DO_NOTHING + # {field_name: (field_name_other_table, other_table, db_on_delete)} expected_relations = { - "reporter_id": ("id", Reporter._meta.db_table), - "response_to_id": ("id", Article._meta.db_table), + "reporter_id": ("id", Reporter._meta.db_table, no_db_on_delete), + "response_to_id": ("id", Article._meta.db_table, no_db_on_delete), } self.assertEqual(relations, expected_relations) @@ -238,6 +244,38 @@ class IntrospectionTests(TransactionTestCase): editor.add_field(Article, body) self.assertEqual(relations, expected_relations) + @skipUnlessDBFeature("can_introspect_foreign_keys") + def test_get_relations_db_on_delete(self): + with connection.cursor() as cursor: + relations = connection.introspection.get_relations( + cursor, DbOnDeleteModel._meta.db_table + ) + + if connection.vendor == "mysql" and connection.mysql_is_mariadb: + no_db_on_delete = None + else: + no_db_on_delete = DO_NOTHING + # {field_name: (field_name_other_table, other_table, db_on_delete)} + expected_relations = { + "fk_db_cascade_id": ("id", City._meta.db_table, DB_CASCADE), + "fk_do_nothing_id": ("id", Country._meta.db_table, no_db_on_delete), + "fk_set_null_id": ("id", Reporter._meta.db_table, DB_SET_NULL), + } + self.assertEqual(relations, expected_relations) + + @skipUnlessDBFeature("can_introspect_foreign_keys", "supports_on_delete_db_default") + def test_get_relations_db_on_delete_default(self): + with connection.cursor() as cursor: + relations = connection.introspection.get_relations( + cursor, DbOnDeleteSetDefaultModel._meta.db_table + ) + + # {field_name: (field_name_other_table, other_table, db_on_delete)} + expected_relations = { + "fk_db_set_default_id": ("id", Country._meta.db_table, DB_SET_DEFAULT), + } + self.assertEqual(relations, expected_relations) + def test_get_primary_key_column(self): with connection.cursor() as cursor: primary_key_column = connection.introspection.get_primary_key_column(