Fixed #36689 -- Fixed top-level JSONField __in lookup failures on MySQL and Oracle.

Added a JSONIn lookup to handle correct serialization and extraction for JSONField __in queries on backends without native JSON support. KeyTransformIn now subclasses JSONIn.

Thanks [USER_REDACTED] for the report.

#### Trac ticket number
<!-- Replace XXXXX with the corresponding Trac ticket number, or delete the line and write "N/A" if this is a trivial PR. -->

ticket-36689

Not sure if a release note is required.

#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

@@ -375,16 +375,124 @@ class JSONIContains(CaseInsensitiveMixin, lookups.IContains):
     pass

+class ProcessJSONLHSMixin:
+    def _get_json_path(self, connection, key_transforms):
+        if key_transforms is None:
+            return "$"
+        return connection.ops.compile_json_path(key_transforms)
+
+    def _process_as_oracle(self, sql, params, connection, key_transforms=None):
+        json_path = self._get_json_path(connection, key_transforms)
+        if connection.features.supports_primitives_in_json_field:
+            template = (
+                "COALESCE("
+                "JSON_VALUE(%s, q'\uffff%s\uffff'),"
+                "JSON_QUERY(%s, q'\uffff%s\uffff' DISALLOW SCALARS)"
+                ")"
+            )
+        else:
+            template = (
+                "COALESCE("
+                "JSON_QUERY(%s, q'\uffff%s\uffff'),"
+                "JSON_VALUE(%s, q'\uffff%s\uffff')"
+                ")"
+            )
+        # Add paths directly into SQL because path expressions cannot be passed
+        # as bind variables on Oracle. Use a custom delimiter to prevent the
+        # JSON path from escaping the SQL literal. Each key in the JSON path is
+        # passed through json.dumps() with ensure_ascii=True (the default),
+        # which converts the delimiter into the escaped \uffff format. This
+        # ensures that the delimiter is not present in the JSON path.
+        sql = template % ((sql, json_path) * 2)
+        return sql, params * 2
+
+    def _process_as_sqlite(self, sql, params, connection, key_transforms=None):
+        json_path = self._get_json_path(connection, key_transforms)
+        datatype_values = ",".join(
+            [repr(value) for value in connection.ops.jsonfield_datatype_values]
+        )
+        return (
+            "(CASE WHEN JSON_TYPE(%s, %%s) IN (%s) "
+            "THEN JSON_TYPE(%s, %%s) ELSE JSON_EXTRACT(%s, %%s) END)"
+        ) % (sql, datatype_values, sql, sql), (*params, json_path) * 3
+
+    def _process_as_mysql(self, sql, params, connection, key_transforms=None):
+        json_path = self._get_json_path(connection, key_transforms)
+        return "JSON_EXTRACT(%s, %%s)" % sql, (*params, json_path)
+
+
+class JSONIn(ProcessJSONLHSMixin, lookups.In):
+    def resolve_expression_parameter(self, compiler, connection, sql, param):
+        sql, params = super().resolve_expression_parameter(
+            compiler,
+            connection,
+            sql,
+            param,
+        )
+        if not connection.features.has_native_json_field and (
+            not hasattr(param, "as_sql") or isinstance(param, expressions.Value)
+        ):
+            if connection.vendor == "oracle":
+                value = param.value if hasattr(param, "value") else json.loads(param)
+                sql = "%s(JSON_OBJECT('value' VALUE %%s FORMAT JSON), '$.value')"
+                if isinstance(value, (list, dict)):
+                    sql %= "JSON_QUERY"
+                else:
+                    sql %= "JSON_VALUE"
+            elif connection.vendor == "mysql" or (
+                connection.vendor == "sqlite"
+                and params[0] not in connection.ops.jsonfield_datatype_values
+            ):
+                sql = "JSON_EXTRACT(%s, '$')"
+        if connection.vendor == "mysql" and connection.mysql_is_mariadb:
+            sql = "JSON_UNQUOTE(%s)" % sql
+        return sql, params
+
+    def process_lhs(self, compiler, connection):
+        sql, params = super().process_lhs(compiler, connection)
+        if isinstance(self.lhs, KeyTransform):
+            return sql, params
+        if connection.vendor == "mysql":
+            return self._process_as_mysql(sql, params, connection)
+        elif connection.vendor == "oracle":
+            return self._process_as_oracle(sql, params, connection)
+        elif connection.vendor == "sqlite":
+            return self._process_as_sqlite(sql, params, connection)
+        return sql, params
+
+    def as_oracle(self, compiler, connection):
+        if (
+            connection.features.supports_primitives_in_json_field
+            and isinstance(self.rhs, expressions.ExpressionList)
+            and JSONNull() in self.rhs.get_source_expressions()
+        ):
+            # Break the lookup into multiple exact lookups combined with OR, as
+            # Oracle does not support directly extracting JSON scalar null as a
+            # value in the right-hand side of an IN clause.
+            exact_lookup = self.lhs.get_lookup("exact")
+            sql_parts = []
+            all_params = ()
+            for expr in self.rhs.get_source_expressions():
+                lookup = exact_lookup(self.lhs, expr)
+                sql, params = lookup.as_oracle(compiler, connection)
+                sql_parts.append(f"({sql})")
+                all_params = (*all_params, *params)
+            sql = " OR ".join(sql_parts)
+            return sql, all_params
+        return self.as_sql(compiler, connection)
+
+
 JSONField.register_lookup(DataContains)
 JSONField.register_lookup(ContainedBy)
 JSONField.register_lookup(HasKey)
 JSONField.register_lookup(HasKeys)
 JSONField.register_lookup(HasAnyKeys)
 JSONField.register_lookup(JSONExact)
 JSONField.register_lookup(JSONIContains)
+JSONField.register_lookup(JSONIn)

-class KeyTransform(Transform):
+class KeyTransform(ProcessJSONLHSMixin, Transform):
     postgres_operator = "->"
     postgres_nested_operator = "#>"

@@ -406,33 +514,11 @@ def preprocess_lhs(self, compiler, connection):

     def as_mysql(self, compiler, connection):
         lhs, params, key_transforms = self.preprocess_lhs(compiler, connection)
-        json_path = connection.ops.compile_json_path(key_transforms)
-        return "JSON_EXTRACT(%s, %%s)" % lhs, (*params, json_path)
+        return self._process_as_mysql(lhs, params, connection, key_transforms)

     def as_oracle(self, compiler, connection):
         lhs, params, key_transforms = self.preprocess_lhs(compiler, connection)
-        json_path = connection.ops.compile_json_path(key_transforms)
-        if connection.features.supports_primitives_in_json_field:
-            sql = (
-                "COALESCE("
-                "JSON_VALUE(%s, q'\uffff%s\uffff'),"
-                "JSON_QUERY(%s, q'\uffff%s\uffff' DISALLOW SCALARS)"
-                ")"
-            )
-        else:
-            sql = (
-                "COALESCE("
-                "JSON_QUERY(%s, q'\uffff%s\uffff'),"
-                "JSON_VALUE(%s, q'\uffff%s\uffff')"
-                ")"
-            )
-        # Add paths directly into SQL because path expressions cannot be passed
-        # as bind variables on Oracle. Use a custom delimiter to prevent the
-        # JSON path from escaping the SQL literal. Each key in the JSON path is
-        # passed through json.dumps() with ensure_ascii=True (the default),
-        # which converts the delimiter into the escaped \uffff format. This
-        # ensures that the delimiter is not present in the JSON path.
-        return sql % ((lhs, json_path) * 2), tuple(params) * 2
+        return self._process_as_oracle(lhs, params, connection, key_transforms)

     def as_postgresql(self, compiler, connection):
         lhs, params, key_transforms = self.preprocess_lhs(compiler, connection)
@@ -447,14 +533,7 @@ def as_postgresql(self, compiler, connection):

     def as_sqlite(self, compiler, connection):
         lhs, params, key_transforms = self.preprocess_lhs(compiler, connection)
-        json_path = connection.ops.compile_json_path(key_transforms)
-        datatype_values = ",".join(
-            [repr(datatype) for datatype in connection.ops.jsonfield_datatype_values]
-        )
-        return (
-            "(CASE WHEN JSON_TYPE(%s, %%s) IN (%s) "
-            "THEN JSON_TYPE(%s, %%s) ELSE JSON_EXTRACT(%s, %%s) END)"
-        ) % (lhs, datatype_values, lhs, lhs), (*params, json_path) * 3
+        return self._process_as_sqlite(lhs, params, connection, key_transforms)

 class KeyTextTransform(KeyTransform):
@@ -535,33 +614,8 @@ def as_sqlite(self, compiler, connection):
         )

-class KeyTransformIn(lookups.In):
-    def resolve_expression_parameter(self, compiler, connection, sql, param):
-        sql, params = super().resolve_expression_parameter(
-            compiler,
-            connection,
-            sql,
-            param,
-        )
-        if (
-            not hasattr(param, "as_sql")
-            and not connection.features.has_native_json_field
-        ):
-            if connection.vendor == "oracle":
-                value = json.loads(param)
-                sql = "%s(JSON_OBJECT('value' VALUE %%s FORMAT JSON), '$.value')"
-                if isinstance(value, (list, dict)):
-                    sql %= "JSON_QUERY"
-                else:
-                    sql %= "JSON_VALUE"
-            elif connection.vendor == "mysql" or (
-                connection.vendor == "sqlite"
-                and params[0] not in connection.ops.jsonfield_datatype_values
-            ):
-                sql = "JSON_EXTRACT(%s, '$')"
-        if connection.vendor == "mysql" and connection.mysql_is_mariadb:
-            sql = "JSON_UNQUOTE(%s)" % sql
-        return sql, params
+class KeyTransformIn(JSONIn):
+    pass

 class KeyTransformExact(JSONExact):

@@ -287,33 +287,28 @@ class FieldGetDbPrepValueIterableMixin(FieldGetDbPrepValueMixin):
     def get_prep_lookup(self):
         if hasattr(self.rhs, "resolve_expression"):
             return self.rhs
-        contains_expr = False
+        if any(hasattr(value, "resolve_expression") for value in self.rhs):
+            # Wrap direct values in Value expressions so they are handled by
+            # the database at compilation time, along with other expressions.
+            return ExpressionList(
+                *[
+                    (
+                        value
+                        if hasattr(value, "resolve_expression")
+                        else Value(value, self.lhs.output_field)
+                    )
+                    for value in self.rhs
+                ]
+            )
         prepared_values = []
         for rhs_value in self.rhs:
-            if hasattr(rhs_value, "resolve_expression"):
-                # An expression will be handled by [NAME_REDACTED] coexist
-                # alongside real values.
-                contains_expr = True
-            elif (
+            if (
                 self.prepare_rhs
                 and hasattr(self.lhs, "output_field")
                 and hasattr(self.lhs.output_field, "get_prep_value")
             ):
                 rhs_value = self.lhs.output_field.get_prep_value(rhs_value)
             prepared_values.append(rhs_value)
-        if contains_expr:
-            return ExpressionList(
-                *[
-                    # Expression defaults `str` to field references while
-                    # lookups default them to literal values.
-                    (
-                        Value(prep_value, self.lhs.output_field)
-                        if isinstance(prep_value, str)
-                        else prep_value
-                    )
-                    for prep_value in prepared_values
-                ]
-            )
         return prepared_values

     def process_rhs(self, compiler, connection):

@@ -1010,6 +1010,19 @@ def test_key_iexact(self):
             NullableJSONModel.objects.filter(value__foo__iexact='"BaR"').exists(), False
         )

+    def test_in(self):
+        tests = [
+            ([[]], [self.objs[1]]),
+            ([{}], [self.objs[2]]),
+            ([{"a": "b", "c": 14}], [self.objs[3]]),
+            ([[1, [2]]], [self.objs[5]]),
+        ]
+        for lookup_value, expected in tests:
+            with self.subTest(value__in=lookup_value):
+                self.assertCountEqual(
+                    NullableJSONModel.objects.filter(value__in=lookup_value), expected
+                )
+
     def test_key_in(self):
         tests = [
             ("value__c__in", [14], self.objs[3:5]),
@@ -1023,6 +1036,7 @@ def test_key_in(self):
                 [self.objs[7]],
             ),
             ("value__foo__in", [F("value__bax__foo")], [self.objs[7]]),
+            ("value__foo__in", [F("value__bax__foo"), {}], [self.objs[7]]),
             (
                 "value__foo__in",
                 [KeyTransform("foo", KeyTransform("bax", "value")), "baz"],
@@ -1031,6 +1045,17 @@ def test_key_in(self):
             ("value__foo__in", [F("value__bax__foo"), "baz"], [self.objs[7]]),
             ("value__foo__in", ["bar", "baz"], [self.objs[7]]),
             ("value__bar__in", [["foo", "bar"]], [self.objs[7]]),
+            ("value__bar__in", [Value(["foo", "bar"], JSONField())], [self.objs[7]]),
+            (
+                "value__bar__in",
+                [["foo", "bar"], Value({}, JSONField())],
+                [self.objs[7]],
+            ),
+            (
+                "value__bar__in",
+                [Value(["foo", "bar"], JSONField()), {"a": "b"}],
+                [self.objs[7]],
+            ),
             ("value__bar__in", [["foo", "bar"], ["a"]], [self.objs[7]]),
             ("value__bax__in", [{"foo": "bar"}, {"a": "b"}], [self.objs[7]]),
             ("value__h__in", [True, "foo"], [self.objs[4]]),
@@ -1297,6 +1322,22 @@ def test_filter(self):
             NullableJSONModel.objects.filter(value__isnull=True), [sql_null]
         )

+    def test_filter_in(self):
+        obj = NullableJSONModel.objects.create(value=JSONNull())
+        obj2 = NullableJSONModel.objects.create(value=[1])
+        self.assertSequenceEqual(
+            NullableJSONModel.objects.filter(value__in=[JSONNull(), [1], "foo"]),
+            [obj, obj2],
+        )
+
+    def test_key_in(self):
+        obj1 = NullableJSONModel.objects.create(value={"key": None})
+        obj2 = NullableJSONModel.objects.create(value={"key": [1]})
+        self.assertSequenceEqual(
+            NullableJSONModel.objects.filter(value__key__in=[JSONNull(), [1], 0]),
+            [obj1, obj2],
+        )
+
     def test_bulk_update(self):
         obj1 = NullableJSONModel.objects.create(value={"k": "1st"})
         obj2 = NullableJSONModel.objects.create(value={"k": "2nd"})

Thanks [USER_REDACTED] for the push 👍

Thanks [USER_REDACTED]  👍

Looking really good, thank you!

Thanks [USER_REDACTED], awesome work.

Let's keep the alphabetization.

This looks subtly different from the logic in `KeyTransform.as_oracle`. Can you share more about how you derived this code, or why we wouldn't factor out the duplication?

I factored out the duplication. LMKWYT!

(The code was derived taking inspiration from `KeyTransform.as_{vendor}` methods, replacing JSON paths with the root path for JSON `Col`s.)

[USER_REDACTED] you were right about there being no way to extract a JSON scalar `null` value in Oracle. Oracle's JSON/SQL functions will convert it to SQL NULL. The way key transform exact handles this is by changing `__key__exact=JSONNull()` to "does the JSON value have the key and is the key NULL. https://github.com/[USER_REDACTED]ngo/django/blob/main/django/db/models/fields/json.py#L594-L597

Perhaps a potential hack in this case would be to rewrite the `IN` clause as an `OR` chain, for example replacing
`IN (v1, v2, …)` with
`JSON_EQUAL(..., v1) OR JSON_EQUAL(..., v2) OR ...` although I'm not sure if it's worth it or not.

Cool. I'll sit with this, but at a glance yeah a skip looks better than a feature flag for this.

> rewrite the IN clause as an OR chain

That seems akin to what we did with composite pk's but I agree would be pretty overkill for a niche use case. Happy to skip as you suggest here.

```suggestion
        if key_transforms is None:
            return "$"
        return connection.ops.compile_json_path(key_transforms)
```

Wondering the reason we need atomic?

I saw that Simon added it for `test__key__in` in 089deb82b9ac2d002af36fd36f288368cdac4b53 (seemingly intentionally? there is no comment), and I thought that maybe it is needed to isolate failures, say in the event of a database error. I'm not too sure, tho'.

> maybe it is needed to isolate failures

Ah, maybe. But then this would fall more on the side of _debugging code_ then? I think we can remove it here.

So I sat with this and managed to get something basically working:

```diff
diff --git a/django/db/backends/oracle/features.py b/django/db/backends/oracle/features.py
index a1bbe24fe5..c07d9f1ed0 100644
--- a/django/db/backends/oracle/features.py
+++ b/django/db/backends/oracle/features.py
@@ -127,9 +127,6 @@ class DatabaseFeatures(BaseDatabaseFeatures):
             "Oracle doesn't support casting filters to NUMBER.": {
                 "lookup.tests.LookupQueryingTests.test_aggregate_combined_lookup",
             },
-            "Oracle doesn't support JSON null scalar extraction.": {
-                "model_fields.test_jsonfield.JSONNullTests.test_filter_in",
-            },
         }
         if self.connection.oracle_version < (23,):
             skips.update(
diff --git a/django/db/models/fields/json.py b/django/db/models/fields/json.py
index 5f0abb3f2e..b901cc4086 100644
--- a/django/db/models/fields/json.py
+++ b/django/db/models/fields/json.py
@@ -6,6 +6,7 @@ from django.core import checks, exceptions
 from django.db import NotSupportedError, connections, router
 from django.db.models import expressions, lookups
 from django.db.models.constants import LOOKUP_SEP
+from django.db.models.expressions import ExpressionList
 from django.db.models.fields import TextField
 from django.db.models.lookups import (
     FieldGetDbPrepValueMixin,
@@ -459,6 +460,27 @@ class JSONIn(ProcessJSONLHSMixin, lookups.In):
             return self._process_as_sqlite(sql, params, connection)
         return sql, params

+    def as_oracle(self, compiler, connection):
+        if (
+            connection.features.supports_primitives_in_json_field
+            and isinstance(self.rhs, ExpressionList)
+            and JSONNull() in self.rhs.get_source_expressions()
+        ):
+            self.rhs.set_source_expressions(
+                [val for val in self.rhs.get_source_expressions() if val != JSONNull()]
+            )
+            lhs_sql, lhs_params = self.process_lhs(compiler, connection)
+            is_null_sql = f"JSON_VALUE({lhs_sql}, '$') IS NULL"
+            try:
+                sql, params = super().as_sql(compiler, connection)
+            except exceptions.EmptyResultSet:
+                sql, params = is_null_sql, lhs_params
+            else:
+                sql += f" OR {is_null_sql}"
+                params = (*lhs_params, *params)
+            return sql, params
+        return self.as_sql(compiler, connection)
+

 JSONField.register_lookup(DataContains)
 JSONField.register_lookup(ContainedBy)
diff --git a/tests/model_fields/test_jsonfield.py b/tests/model_fields/test_jsonfield.py
index bb69955e56..0d7eae0716 100644
--- a/tests/model_fields/test_jsonfield.py
+++ b/tests/model_fields/test_jsonfield.py
@@ -1309,6 +1309,11 @@ class JSONNullTests(TestCase):
             NullableJSONModel.objects.filter(value__in=[JSONNull()]),
             [obj],
         )
+        obj2 = NullableJSONModel.objects.create()
+        self.assertCountEqual(
+            NullableJSONModel.objects.filter(value__in=[JSONNull(), None]),
+            [obj, obj2],
+        )

     def test_bulk_update(self):
         obj1 = NullableJSONModel.objects.create(value={"k": "1st"})

```

Would you be able to have a look and let me know what you think? I know you mentioned not being able to pull the docker image, so if it's too much trouble just say so. 👍

Thanks [USER_REDACTED]! This is better than an `OR` chain.

There is, however, currently one issue with this. Since all direct values are wrapped in expressions, e.g. `Value({}, output_field())`, for them to be source expressions of `ExpressionList`, it means that these values won't be serialized by `JSONIn.resolve_expression_parameter`. As such, I was expecting the following (note the the extra filter value for `obj3`) to fail with
> django.db.utils.DatabaseError: ORA-22848: cannot use NCLOB type as comparison key
Help: https://docs.oracle.com/error-help/db/ora-22848/

```py
    def test_filter_in(self):
        obj = NullableJSONModel.objects.create(value=JSONNull())
        self.assertSequenceEqual(
            NullableJSONModel.objects.filter(value__in=[JSONNull()]),
            [obj],
        )
        obj2 = NullableJSONModel.objects.create()
        obj3 = NullableJSONModel.objects.create(value={"k": "v"})
        self.assertCountEqual(
            NullableJSONModel.objects.filter(value__in=[JSONNull(), None, {"k": "v"}]),
            [obj, obj2, obj3],
        )
```

However the error I am getting is not expected.

ERROR: test_filter_in (model_fields.test_jsonfield.JSONNullTests.test_filter_in)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/django/source/tests/model_fields/test_jsonfield.py", line 1314, in test_filter_in
    self.assertCountEqual(
  File "/usr/local/lib/python3.12/unittest/case.py", line 1198, in assertCountEqual
    first_seq, second_seq = list(first), list(second)
                            ^^^^^^^^^^^
  File "/django/source/django/db/models/query.py", line 406, in __iter__
    self._fetch_all()
  File "/django/source/django/db/models/query.py", line 2088, in _fetch_all
    self._result_cache = list(self._iterable_class(self))
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/django/source/django/db/models/query.py", line 96, in __iter__
    results = compiler.execute_sql(
              ^^^^^^^^^^^^^^^^^^^^^
  File "/django/source/django/db/models/sql/compiler.py", line 1623, in execute_sql
    cursor.execute(sql, params)
  File "/django/source/django/db/backends/utils.py", line 79, in execute
    return self._execute_with_wrappers(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/django/source/django/db/backends/utils.py", line 92, in _execute_with_wrappers
    return executor(sql, params, many, context)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/django/source/django/db/backends/utils.py", line 105, in _execute
    return self.cursor.execute(sql, params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/django/source/django/db/backends/oracle/base.py", line 625, in execute
    query, params = self._fix_for_params(query, params, unify_by_values=True)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/django/source/django/db/backends/oracle/base.py", line 611, in _fix_for_params
    for i, param_type in enumerate(dict.fromkeys(param_types))
                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: unhashable type: 'dict'

----------------------------------------------------------------------
Ran 1 test in 0.206s

The same unexpected error happens on main on `__key__in` if a Value instance with `value=list|dict` is included in the list of RHS values:
```diff
diff --git a/tests/model_fields/test_jsonfield.py b/tests/model_fields/test_jsonfield.py
index b16499d198..698a238d1c 100644
--- a/tests/model_fields/test_jsonfield.py
+++ b/tests/model_fields/test_jsonfield.py
@@ -1016,6 +1016,11 @@ class TestQuerying(TestCase):
             ("value__bar__in", [["foo", "bar"]], [self.objs[7]]),
             ("value__bar__in", [["foo", "bar"], ["a"]], [self.objs[7]]),
             ("value__bax__in", [{"foo": "bar"}, {"a": "b"}], [self.objs[7]]),
+            (
+                "value__bax__in",
+                [Value({"foo": "bar"}, JSONField()), {"a": "b"}],
+                [self.objs[7]],
+            ),
             ("value__h__in", [True, "foo"], [self.objs[4]]),
             ("value__i__in", [False, "foo"], [self.objs[4]]),
         ]
```
Maybe we have a separate bug? 🤔
It also fails on sqlite3

ERROR: test_key_in (model_fields.test_jsonfield.TestQuerying.test_key_in) [<object object at 0x7d8546004670>] (lookup='value__bax__in', value=[Value({'foo': 'bar'}), {'a': 'b'}])
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/clifford/code/django/django/db/backends/utils.py", line 105, in _execute
    return self.cursor.execute(sql, params)
    ^^^^^^^
  File "/home/clifford/code/django/django/db/backends/sqlite3/base.py", line 359, in execute
    return super().execute(query, params)
    ^^^^^^^^^^^^^^^
sqlite3.ProgrammingError: Error binding parameter 5: type 'dict' is not supported

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/clifford/.pyenv/versions/3.14.0/lib/python3.14/unittest/case.py", line 58, in testPartExecutor
    yield
  File "/home/clifford/.pyenv/versions/3.14.0/lib/python3.14/unittest/case.py", line 565, in subTest
    yield
  File "/home/clifford/code/django/tests/model_fields/test_jsonfield.py", line 1029, in test_key_in
    self.assertCountEqual(
    ^^^^^^^
  File "/home/clifford/.pyenv/versions/3.14.0/lib/python3.14/unittest/case.py", line 1238, in assertCountEqual
    first_seq, second_seq = list(first), list(second)
    ^^^^^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/models/query.py", line 406, in __iter__
    self._fetch_all()
    ^^^^^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/models/query.py", line 2086, in _fetch_all
    self._result_cache = list(self._iterable_class(self))
    ^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/models/query.py", line 96, in __iter__
    results = compiler.execute_sql(
    ^^^^^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/models/sql/compiler.py", line 1623, in execute_sql
    cursor.execute(sql, params)
    ^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/backends/utils.py", line 79, in execute
    return self._execute_with_wrappers(
    ^^^^^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/backends/utils.py", line 92, in _execute_with_wrappers
    return executor(sql, params, many, context)
    ^^^^^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/backends/utils.py", line 100, in _execute
    with self.db.wrap_database_errors:
    ^^^^^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/utils.py", line 94, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
    ^^^^^^^
  File "/home/clifford/code/django/django/db/backends/utils.py", line 105, in _execute
    return self.cursor.execute(sql, params)
    ^^^^^^^
  File "/home/clifford/code/django/django/db/backends/sqlite3/base.py", line 359, in execute
    return super().execute(query, params)
    ^^^^^^^^^^^^^^^
django.db.utils.ProgrammingError: Error binding parameter 5: type 'dict' is not supported

----------------------------------------------------------------------
Ran 102 tests in 2.353s

I also struggled with that, and that explains why I played with `__in=[JSONNull(), None]` -- all I wanted was a second parameter, and `None` was the easiest thing to provide. (But now I see an issue with providing `None` there is that the behavior isn't the same on all backends, so let's remove it.)  I think it would be better to use `JSONObject(k=Value("v")` for now.

If you think there is a separate issue here where we can improve handling of list/dict params we should probably get a new ticket triaged for it and evaluate it on each backend.

I also think I'm missing a `clone = self.rhs.copy()` before mutating the source expressions.

I do see this now with `JSONObject`:

> django.db.utils.DatabaseError: ORA-22848: cannot use NCLOB type as comparison key
Help: https://docs.oracle.com/error-help/db/ora-22848/

Do you have an idea about this?

It also fails on postgres

ERROR: test_key_in (model_fields.test_jsonfield.TestQuerying.test_key_in) [<object object at 0x7994c6bb4360>] (lookup='value__bax__in', value=[Value({'foo': 'bar'}), {'a': 'b'}])
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/clifford/code/django/django/db/backends/utils.py", line 105, in _execute
    return self.cursor.execute(sql, params)
    ^^^^^^^
  File "/home/clifford/.pyenv/versions/dj/lib/python3.14/site-packages/psycopg/cursor.py", line 97, in execute
    raise ex.with_traceback(None)
    ^^^^^^^^^^^
psycopg.ProgrammingError: cannot adapt type 'dict' using placeholder '%t' (format: TEXT)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/clifford/.pyenv/versions/3.14.0/lib/python3.14/unittest/case.py", line 58, in testPartExecutor
    yield
  File "/home/clifford/.pyenv/versions/3.14.0/lib/python3.14/unittest/case.py", line 565, in subTest
    yield
  File "/home/clifford/code/django/tests/model_fields/test_jsonfield.py", line 1029, in test_key_in
    self.assertCountEqual(
    ^^^^^^^
  File "/home/clifford/.pyenv/versions/3.14.0/lib/python3.14/unittest/case.py", line 1238, in assertCountEqual
    first_seq, second_seq = list(first), list(second)
    ^^^^^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/models/query.py", line 406, in __iter__
    self._fetch_all()
    ^^^^^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/models/query.py", line 2086, in _fetch_all
    self._result_cache = list(self._iterable_class(self))
    ^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/models/query.py", line 96, in __iter__
    results = compiler.execute_sql(
    ^^^^^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/models/sql/compiler.py", line 1623, in execute_sql
    cursor.execute(sql, params)
    ^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/backends/utils.py", line 79, in execute
    return self._execute_with_wrappers(
    ^^^^^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/backends/utils.py", line 92, in _execute_with_wrappers
    return executor(sql, params, many, context)
    ^^^^^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/backends/utils.py", line 100, in _execute
    with self.db.wrap_database_errors:
    ^^^^^^^^^^^^^^^
  File "/home/clifford/code/django/django/db/utils.py", line 94, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
    ^^^^^^^
  File "/home/clifford/code/django/django/db/backends/utils.py", line 105, in _execute
    return self.cursor.execute(sql, params)
    ^^^^^^^
  File "/home/clifford/.pyenv/versions/dj/lib/python3.14/site-packages/psycopg/cursor.py", line 97, in execute
    raise ex.with_traceback(None)
    ^^^^^^^^^^^
django.db.utils.ProgrammingError: cannot adapt type 'dict' using placeholder '%t' (format: TEXT)

----------------------------------------------------------------------
Ran 102 tests in 3.359s

> Do you have an idea about this?

I will look into it 👍🏾

Grepping for the NCLOB stuff, I see this `== "TextField"` that maybe should be `in ["TextField", "JSONField"]`?

https://github.com/[USER_REDACTED]ngo/django/blob/6d4d99b3cef4a6d931de02f89a493fb345dc438e/django/db/models/functions/comparison.py#L88-L91

> Grepping for the NCLOB stuff, I see this == "TextField" that maybe should be in ["TextField", "JSONField"]?

I'd say yes, since Django saves `JSONField` as NCLOB on Oracle, although given how strict Oracle is with LOBs we'd probably want to add a few extra tests to ensure that nothing breaks?

> I do see this now with `JSONObject`:

From a glance at the queries, I think it's because `JSONObject` is explicitly `RETURNING CLOB` and LOB cannot be used for comparison. Although I'm not sure that we want to add support for `JSONObject()` seeing as there are no tests related to `JSONField`?

Hello, [USER_REDACTED] 👋🏾

RE: your `as_oracle` to allow "OR ... IS NULL"

I struggled to make it work in a way I was happy with. For context, the following are some specific things that I had to take into account.
1. The `IS NULL` check is not the same for top-level `JSONField` and for keys, (which were overlooked). To make sure a key truly has "null" as a value, we need to first perform a `JSON_EXISTS` (`__haskey` lookup) on the key and then do a `JSON_VALUE(...) IS NULL` (`__isnull`). This is because `JSON_VALUE(..) IS NULL` will also be true for nonexistent keys.
3. Because of the above, we also have to handle `None` values (at least when lhs is a keytransform), since they are synonymous with `JSONNull()` inside an array or json document.
4. `JSON_VALUE(...)` is fine for the case when what the LHS returns is a JSON scalar value, otherwise we have to use `JSON_QUERY(...)` using `COALESCE(...)` as is done when processing other oracle lhs in these lookups.
5. As you identified in the patch, we need to handle cases when `JSONNull()/None` may be the only values in the iterable rhs.

Given that this is non-trivial, can we skip the test on oracle for now and follow up with this in a separate work, if we choose to follow up? If so, I'd like to try chaining `OR` instead of doing an `IN () OR ... IS NULL` in a follow up.

(RE: The extra commit:

Thinking of an alternative solution 🤔. Do you have any idea why `FieldGetDbPrepValueIterableMixin` does not call any `get_db_prep_value()` despite its name seemingly promising that it will? ... I understand values are already "prepped" by [NAME_REDACTED] wrapped in `ExpressionList`, but this doesn't mean much for JSONField since it doesn't override `get_prep_value()`...)

> Given that this is non-trivial, can we skip the test on oracle for now and follow up with this in a separate work, if we choose to follow up? If so, I'd like to try chaining OR instead of doing an IN () OR ... IS NULL in a follow up.

For sure. This is getting pretty baroque. Let's wait until someone needs it. Thanks for giving it a look though.

Can you say more about how you worked out this should check for compilables and not resolvables? I get no failures when I change this to `"resolve_expression"`.

> Do you have any idea why FieldGetDbPrepValueIterableMixin does not call any get_db_prep_value() despite its name seemingly promising that it will?

It inherits from `FieldGetDbPrepValueMixin`, which does provide a `get_db_prep_lookup()` that calls `get_db_prep_value()`, looks relevant?

That was a mistake, it should check for resolvables instead. Thanks.

The issue is that once an iterable RHS is wrapped in `ExpressionList`, its elements become expressions, so `get_db_prep_value()` is no longer called for literal values.

>  looks relevant?

I thought `get_db_prep_lookup()` might help, but it can’t, because the RHS has already been turned into expressions by [NAME_REDACTED] runs.

Using `Value(..., output_field=...)` ensures that `get_db_prep_value()` [is applied to every value](https://github.com/[USER_REDACTED]ngo/django/blob/0eec2a163a4b2ea4e82c53ae1d7b71bf43ac911d/django/db/models/expressions.py#L1171) in the RHS.

We can return early since direct values will be prepped at compilation time by `Value.as_sql()`; there's no need to prep them here.

> This is getting pretty baroque. Let's wait until someone needs it.

[USER_REDACTED] I decided to give it another shot with fresh eyes. It seems like the logic becomes simplified if we just `exact` and chain everything instead of special-casing `JSONNull()` alone.

Somewhere in this vicinity we might keep the comment "an expression will be handled by [NAME_REDACTED]"

Let's avoid the `+` and favor `*`, given how much trouble we've had with `TypeError` for tuple + list. It *shouldn't* be an issue now, but who knows what subclasses are doing in the wild...

Playing with this test case (using `In()` rather than `__in`) revealed ticket-36787, but we don't have to block on that here.


Added community package storage backends mention to docs.

#### Trac ticket number
N/A

#### Branch description
This adds an admonition to the storages documentation about the community packages. It references both the [NAME_REDACTED] page and the [NAME_REDACTED] grid.

This is related to https://github.com/[USER_REDACTED]ngo/new-features/issues/39

#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [ ] I have checked the "Has patch" ticket flag in the Trac system.
- [ ] I have added or updated relevant tests.
- [x] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

@@ -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 [NAME_REDACTED] ``NotImplementedError`` instead.
+
+.. admonition:: There are community-maintained solutions too!
+
+    Django has a vibrant ecosystem. There are storage backends
+    highlighted on the `[NAME_REDACTED]`_ page. The Django Packages
+    `[NAME_REDACTED] 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/

@@ -2766,6 +2766,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 `[NAME_REDACTED]`_ page. The Django Packages
+    `[NAME_REDACTED] 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``

Looks good!

and below
```suggestion
.. admonition:: There are community-maintained solutions too!
```


Fixed #36624 -- Dropped support for MySQL < 8.4.

ticket-36624

- [x] CI upgraded

@@ -76,8 +76,6 @@ def disallowed_aggregates(self):
         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 = {

@@ -10,16 +10,6 @@
 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))

@@ -144,11 +144,8 @@ def data_types(self):
             _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",

@@ -66,7 +66,7 @@ def minimum_database_version(self):
         if self.connection.mysql_is_mariadb:
             return (10, 6)
         else:
-            return (8, 0, 11)
+            return (8, 4)

     [USER_REDACTED]_property
     def test_collations(self):
@@ -104,24 +104,6 @@ def django_test_skips(self):
                 "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 @@ def has_zoneinfo_database(self):
     def is_sql_auto_is_null_enabled(self):
         return self.connection.mysql_server_data["sql_auto_is_null"]

-    [USER_REDACTED]_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")
-    )
-
-    [USER_REDACTED]_property
-    def can_introspect_check_constraints(self):
-        if self.connection.mysql_is_mariadb:
-            return True
-        return self.connection.mysql_version >= (8, 0, 16)
-
     [USER_REDACTED]_property
     def has_select_for_update_of(self):
         return not self.connection.mysql_is_mariadb

-    [USER_REDACTED]_property
-    def supports_explain_analyze(self):
-        return self.connection.mysql_is_mariadb or self.connection.mysql_version >= (
-            8,
-            0,
-            18,
-        )
-
     [USER_REDACTED]_property
     def supported_explain_formats(self):
         # [NAME_REDACTED] 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 @@ def supports_expression_indexes(self):
         return (
             not self.connection.mysql_is_mariadb
             and self._mysql_storage_engine != "MyISAM"
-            and self.connection.mysql_version >= (8, 0, 13)
         )

-    [USER_REDACTED]_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")
-    )
-
-    [USER_REDACTED]_property
-    def supports_expression_defaults(self):
-        if self.connection.mysql_is_mariadb:
-            return True
-        return self.connection.mysql_version >= (8, 0, 13)
-
     [USER_REDACTED]_property
     def has_native_uuid_field(self):
         is_mariadb = self.connection.mysql_is_mariadb

@@ -349,7 +349,7 @@ def explain_query_prefix(self, format=None, **options):
             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 @@ def conditional_expression_supported_in_where_clause(self, expression):
     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)"

@@ -65,33 +65,19 @@ def skip_default(self, field):
         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
             # ALTER COLUMN statement.
             return True
         return False

-    [USER_REDACTED]
-    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)"

@@ -58,7 +58,7 @@ supported versions, and any notes for each of the supported database backends:
 [NAME_REDACTED]            [NAME_REDACTED]
 ==================  ==============================  ==================  =========================================
 PostgreSQL          GEOS, GDAL, PROJ, PostGIS       15+                 [NAME_REDACTED].
-MySQL               GEOS, GDAL                      8.0.11+             :ref:`Limited functionality <mysql-spatial-limitations>`.
+MySQL               GEOS, GDAL                      8.4+                :ref:`Limited functionality <mysql-spatial-limitations>`.
 Oracle              GEOS, GDAL                      19+                 XE not supported.
 SQLite              GEOS, GDAL, PROJ, SpatiaLite    3.37.0+             [NAME_REDACTED] 4.3+
 ==================  ==============================  ==================  =========================================

@@ -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.

@@ -63,10 +63,9 @@ and the ``weight`` rounded to the nearest integer.
     error. This means that functions such as
     :class:`Concat() <django.db.models.functions.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``
 ----------

@@ -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:

@@ -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
 -------------

@@ -109,8 +109,8 @@ def test_check_database_version_supported(self, mocked_get_database_version):
             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()

@@ -159,9 +159,7 @@ def test_mysql_text_to_traditional(self):
         self.assertEqual(len(captured_queries), 1)
         self.assertIn("FORMAT=TRADITIONAL", captured_queries[0]["sql"])

-    [USER_REDACTED].skipUnless(
-        connection.vendor == "mysql", "MariaDB and MySQL >= 8.0.18 specific."
-    )
+    [USER_REDACTED].skipUnless(connection.vendor == "mysql", "MySQL specific")
     def test_mysql_analyze(self):
         qs = Tag.objects.filter(name="test")
         with CaptureQueriesContext(connection) as captured_queries:

Small admonition tweak but LGTM otherwise thank you!

```suggestion
.. admonition:: MariaDB
```

Good catch :dart:

This test passes on mariadb, so I think we can remove the skip. Debatable whether we should fix in this PR, but we are removing the dynamic skip for this test elsewhere in the PR.

MySQL and MariaDB share the same vendor name `mysql`, this test is skipped on other vendors, not on MySQL or MariaDB.

Oh right, thank you!


Fixed #36686 -- [NAME_REDACTED] is ignored in GROUP BY queries.

#### Trac ticket number

ticket-36686

#### Branch description

This PR addresses ticket #36686 by [NAME_REDACTED] a model's `Meta.ordering` (default ordering) is not respected in `GROUP BY` queries, such as those using `.values()` and `.annotate()`.

The current documentation was misleading, particularly the note stating Django "never removes ordering constraints," which caused confusion.

This patch fixes the documentation in three key places:
1.  `docs/topics/db/aggregation.txt`: Clarifies the "never removes" note to apply only to explicit `.order_by()` calls.
~~2.  `docs/ref/models/querysets.txt`: Corrects a related misleading note in the `distinct()` documentation, as pointed out in comment 3.~~
3.  `docs/ref/models/options.txt`: Adds a new warning note directly to the `Meta.ordering` definition itself.

#### Checklist
- [X] This PR targets the `main` branch.
- [X] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [X] I have checked the "Has patch" ticket flag in the Trac system.
- [X] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have added or updated relevant tests.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

@@ -312,6 +312,12 @@ not be looking at your Django code. For example::

         ordering = [F("author").asc(nulls_last=True)]

+.. admonition:: Default ordering and GROUP BY
+
+    In :ref:`GROUP BY queries <aggregation-ordering-interaction>` (for example,
+    those using :meth:`~.QuerySet.values` and :meth:`~.QuerySet.annotate`), the
+    default ordering is not applied.
+
 .. warning::

     Ordering is not a free operation. Each field you add to the ordering

@@ -627,8 +627,15 @@ fields you also select in a ``values()`` call.
     You might reasonably ask why Django doesn't remove the extraneous columns
     for you. The main reason is consistency with ``distinct()`` and other
     places: Django **never** removes ordering constraints that you have
-    specified (and we can't change those other methods' behavior, as that
-    would violate our :doc:`/misc/api-stability` policy).
+    specified *explicitly with* ``order_by()`` (and we can't change those
+    other methods' behavior, as that would violate our
+    :doc:`/misc/api-stability` policy).
+
+.. admonition:: Default ordering not applied to GROUP BY
+
+    ``GROUP BY`` queries (for example, those using ``.values()`` and
+    ``.annotate()``) don't use the model's default ordering.
+    Use ``order_by()`` explicitly when a given order is needed.

 Aggregating annotations
 -----------------------

Thanks for the PR ⭐

Note that group by [NAME_REDACTED] _and_ values.

Thanks, this is very close, just a couple tweaks left. Sorry for nudging you to update the `distinct()` docs, we can leave that alone.

Added some suggestions, thank you all!

I think this should be a separate note.

```suggestion
    :meth:`~.QuerySet.annotate` and :meth:`~.QuerySet.values`.
```

```suggestion
    ``GROUP BY`` queries (such as those using ``.annotate()`` and
```

Also, let's avoid repeating "without ``.order_by()``" here, I don't think it helps.

Please try to avoid a bare `note` and use `admonition` instead.

[USER_REDACTED] do you know why we consider `note` to be more generic than `admonition`? It seems `admonition` is [the generic one](https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html#rst-directives). Or do you prefer the generic one where possible?

Oh, is it that because "Note" will appear in the built doc? I see.

because admonitions have titles, https://docs.djangoproject.com/en/stable/internals/contributing/writing-documentation/#guidelines-for-restructuredtext-files

I was wrong to suggest changes here. `Meta.ordering` still affects `.distinct()` calls in the way described here because it involves the `SELECT` clause and not the `GROUP BY`. Mind reverting?

Let's reverse the order here. The doc'd examples usually show `values()` then `annotate()`, because the results can be subtly different otherwise.

Example:
https://github.com/[USER_REDACTED]ngo/django/blob/05ba1a9228128614fb3c475f1c4bdf0160f44dba/docs/ref/models/querysets.txt#L652-L653

Explanation:
https://forum.djangoproject.com/t/group-by-and-having/41574/2 (thanks [USER_REDACTED]!)

reverse order here also.

Maybe [USER_REDACTED] has an opinion here? The release note from the original ticket changing this behavior used ``.annotate().values()`` 🤔

This reads a little awkwardly to me, specially the "such as those", I find that distracting. How about:

```suggestion
    In :ref:`GROUP BY queries <aggregation-ordering-interaction>` (for example,
    those using :meth:`~.QuerySet.values` or `:meth:~.QuerySet.annotate`), the
    default ordering is not applied.
```

We don't generally use "curly quotes". How about:
```suggestion
    ``GROUP BY`` queries (for example, those using ``.values()`` and
    ``.annotate()``) don't use the model's default ordering.
    Use ``order_by()`` explicitly when a given order is needed.
```

Agree with the wording simplifications, but just noting that I previously asked for "and" rather than "or" since both are required for a GROUP BY query.


Fixed IntegrityError on 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.
Updated tests to dynamically select an unused primary key instead.

This fixes traceback from https://djangoci.com/job/main-random/database=postgres,label=noble,python=python3.14/226/testReport/junit/bulk_create.tests/BulkCreateTransactionTests/test_no_unnecessary_transaction/:

```
Traceback (most recent call last):
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/django/db/backends/utils.py", line 105, in _execute
    return self.cursor.execute(sql, params)
           ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/tests/.venv/lib/python3.14/site-packages/psycopg/cursor.py", line 97, in execute
    raise ex.with_traceback(None)
psycopg.errors.UniqueViolation: duplicate key value violates unique constraint "bulk_create_country_pkey"
DETAIL:  Key (id)=(1) already exists.

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/tests/bulk_create/tests.py", line 898, in test_no_unnecessary_transaction
    Country.objects.bulk_create([Country(name="Canada", iso_two_letter="CA")])
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/django/db/models/manager.py", line 87, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/django/db/models/query.py", line 850, in bulk_create
    returned_columns = self._batched_insert(
        objs_without_pk,
    ...<4 lines>...
        unique_fields=unique_fields,
    )
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/django/db/models/query.py", line 2041, in _batched_insert
    self._insert(
    ~~~~~~~~~~~~^
        item,
        ^^^^^
    ...<5 lines>...
        returning_fields=returning_fields,
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/django/db/models/query.py", line 2003, in _insert
    return query.get_compiler(using=using).execute_sql(returning_fields)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/django/db/models/sql/compiler.py", line 1925, in execute_sql
    cursor.execute(sql, params)
    ~~~~~~~~~~~~~~^^^^^^^^^^^^^
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/django/db/backends/utils.py", line 122, in execute
    return super().execute(sql, params)
           ~~~~~~~~~~~~~~~^^^^^^^^^^^^^
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/django/db/backends/utils.py", line 79, in execute
    return self._execute_with_wrappers(
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~^
        sql, params, many=False, executor=self._execute
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/django/db/backends/utils.py", line 92, in _execute_with_wrappers
    return executor(sql, params, many, context)
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/django/db/backends/utils.py", line 100, in _execute
    with self.db.wrap_database_errors:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/django/db/utils.py", line 94, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/django/db/backends/utils.py", line 105, in _execute
    return self.cursor.execute(sql, params)
           ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
  File "/home/jenkins/workspace/main-random/database/postgres/label/noble/python/python3.14/tests/.venv/lib/python3.14/site-packages/psycopg/cursor.py", line 97, in execute
    raise ex.with_traceback(None)
django.db.utils.IntegrityError: duplicate key value violates unique constraint "bulk_create_country_pkey"
DETAIL:  Key (id)=(1) already exists.
```

@@ -889,19 +889,27 @@ def test_db_default_primary_key(self):
 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"),
                 ]
             )

Maybe

```suggestion
        # 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.
        unused_id = getattr(Country.objects.last(), "id", 10) + 100
```

Great suggestion, thank you, applying!


Added tests for MultiPointField, MultiLineStringField, and GeometryCollectionField.

While developing the MongoDB backend, I noticed these fields don't have any tests.

@@ -102,3 +102,15 @@ class ManyPointModel(NamedModel):
     point1 = models.PointField()
     point2 = models.PointField()
     point3 = models.PointField(srid=3857)
+
+
+class Points(models.Model):
+    geom = models.MultiPointField()
+
+
+class Lines(models.Model):
+    geom = models.MultiLineStringField()
+
+
+class GeometryCollectionModel(models.Model):
+    geom = models.GeometryCollectionField()

@@ -14,13 +14,12 @@
     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 F, IntegerField, Sum, Value
 from django.test import TestCase, skipUnlessDBFeature

-from ..utils import FuncTestMixin
+from ..utils import FuncTestMixin, can_save_multipoint
 from .models import (
     City,
     Country,
@@ -965,12 +964,7 @@ def test_geometry_type(self):
             ("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):
+        if can_save_multipoint:
             test_features.append(
                 Feature(name="MultiPoint", geom=MultiPoint(Point(0, 0), Point(1, 1)))
             )

@@ -1,4 +1,5 @@
 from io import StringIO
+from unittest import skipIf

 from django.contrib.gis import gdal
 from django.contrib.gis.db.models import Extent, MakeLine, Union, functions
@@ -21,15 +22,18 @@
 from django.test import TestCase, skipUnlessDBFeature
 from django.test.utils import CaptureQueriesContext

-from ..utils import skipUnlessGISLookup
+from ..utils import cannot_save_multipoint, skipUnlessGISLookup
 from .models import (
     City,
     Country,
     Feature,
+    GeometryCollectionModel,
+    Lines,
     MinusOneSRID,
     MultiFields,
     NonConcreteModel,
     PennsylvaniaCity,
+    Points,
     State,
     ThreeDimensionalFeature,
     Track,
@@ -269,6 +273,48 @@ def test_empty_geometries(self):
             self.assertEqual(feature.geom.srid, g.srid)

+class SaveLoadTests(TestCase):
+    def test_multilinestringfield(self):
+        geom = MultiLineString(
+            LineString((0, 0), (1, 1), (5, 5)),
+            LineString((0, 0), (0, 5), (5, 5), (5, 0), (0, 0)),
+        )
+        obj = Lines.objects.create(geom=geom)
+        obj.refresh_from_db()
+        self.assertEqual(obj.geom.tuple, geom.tuple)
+
+    def test_multilinestring_with_linearring(self):
+        geom = MultiLineString(
+            LineString((0, 0), (1, 1), (5, 5)),
+            LinearRing((0, 0), (0, 5), (5, 5), (5, 0), (0, 0)),
+        )
+        obj = Lines.objects.create(geom=geom)
+        obj.refresh_from_db()
+        self.assertEqual(obj.geom.tuple, geom.tuple)
+        self.assertEqual(obj.geom[1].__class__.__name__, "LineString")
+        self.assertEqual(obj.geom[0].tuple, geom[0].tuple)
+        # LinearRings are transformed to LineString.
+        self.assertEqual(obj.geom[1].__class__.__name__, "LineString")
+        self.assertEqual(obj.geom[1].tuple, geom[1].tuple)
+
+    [USER_REDACTED](cannot_save_multipoint, "MariaDB cannot save MultiPoint due to a bug.")
+    def test_multipointfield(self):
+        geom = MultiPoint(Point(1, 1), Point(0, 0))
+        obj = Points.objects.create(geom=geom)
+        obj.refresh_from_db()
+        self.assertEqual(obj.geom, geom)
+
+    def test_geometrycollectionfield(self):
+        geom = GeometryCollection(
+            Point(2, 2),
+            LineString((0, 0), (2, 2)),
+            Polygon(LinearRing((0, 0), (0, 5), (5, 5), (5, 0), (0, 0))),
+        )
+        obj = GeometryCollectionModel.objects.create(geom=geom)
+        obj.refresh_from_db()
+        self.assertIs(obj.geom.equals(geom), True)
+
+
 class GeoLookupTest(TestCase):
     fixtures = ["initial"]

@@ -4,6 +4,7 @@
 from unittest import mock

 from django.conf import settings
+from django.contrib.gis.geos.libgeos import geos_version_tuple
 from django.db import DEFAULT_DB_ALIAS, connection
 from django.db.models import Func

@@ -31,6 +32,12 @@ def skip_wrapper(*args, **kwargs):
 _default_db = settings.DATABASES[DEFAULT_DB_ALIAS]["ENGINE"].rsplit(".")[-1]
 # MySQL spatial indices can't handle NULL geometries.
 gisfield_may_be_null = _default_db != "mysql"
+# 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.
+cannot_save_multipoint = connection.ops.mariadb and geos_version_tuple() >= (3, 12)
+can_save_multipoint = not cannot_save_multipoint

 class FuncTestMixin:

I'm not sure why this fails on MariaDB. Possibly [the query doesn't use the correct syntax](https://dba.stackexchange.com/questions/278787/mariadb-error-1048-geomfromtext-returning-as-null):
```
Traceback (most recent call last):
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/tests/gis_tests/geoapp/tests.py", line 301, in test_multipointfield
    obj = Points.objects.create(geom=geom)
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/models/manager.py", line 87, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/models/query.py", line 685, in create
    obj.save(force_insert=True, using=self.db)
    ~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/models/base.py", line 893, in save
    self.save_base(
    ~~~~~~~~~~~~~~^
        using=using,
        ^^^^^^^^^^^^
    ...<2 lines>...
        update_fields=update_fields,
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/models/base.py", line 985, in save_base
    updated = self._save_table(
        raw,
    ...<4 lines>...
        update_fields,
    )
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/models/base.py", line 1186, in _save_table
    results = self._do_insert(
        cls._base_manager, using, insert_fields, returning_fields, raw
    )
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/models/base.py", line 1238, in _do_insert
    return manager._insert(
           ~~~~~~~~~~~~~~~^
        [self],
        ^^^^^^^
    ...<3 lines>...
        raw=raw,
        ^^^^^^^^
    )
    ^
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/models/manager.py", line 87, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/models/query.py", line 2003, in _insert
    return query.get_compiler(using=using).execute_sql(returning_fields)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/models/sql/compiler.py", line 1925, in execute_sql
    cursor.execute(sql, params)
    ~~~~~~~~~~~~~~^^^^^^^^^^^^^
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/backends/utils.py", line 79, in execute
    return self._execute_with_wrappers(
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~^
        sql, params, many=False, executor=self._execute
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/backends/utils.py", line 92, in _execute_with_wrappers
    return executor(sql, params, many, context)
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/backends/utils.py", line 100, in _execute
    with self.db.wrap_database_errors:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/utils.py", line 94, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/backends/utils.py", line 105, in _execute
    return self.cursor.execute(sql, params)
           ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/django/db/backends/mysql/base.py", line 78, in execute
    return self.cursor.execute(query, args)
           ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/tests/.venv/lib/python3.14/site-packages/MySQLdb/cursors.py", line 179, in execute
    res = self._query(mogrified_query)
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/tests/.venv/lib/python3.14/site-packages/MySQLdb/cursors.py", line 331, in _query
    self._do_get_result(db)
    ~~~~~~~~~~~~~~~~~~~^^^^
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/tests/.venv/lib/python3.14/site-packages/MySQLdb/cursors.py", line 136, in _do_get_result
    self._result = result = self._get_result()
                            ~~~~~~~~~~~~~~~~^^
  File "/home/jenkins/workspace/pr-mariadb/database/mysql_gis/label/mariadb/python/python3.14/tests/.venv/lib/python3.14/site-packages/MySQLdb/cursors.py", line 363, in _get_result
    return self._get_db().store_result()
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
django.db.utils.IntegrityError: (1048, "Column 'geom' cannot be null")
```

It is a bug in MariaDB, see 5a2490a19d81f6f2666dd90ce79e5724b9a20a39.

I'd skip it on MariaDB and GEOS 3.12+.

Thanks for the pointer.

Any pointer about Oracle failure?
```
Traceback (most recent call last):
  File "/tests/gis_tests/geoapp/tests.py", line 315, in test_geometrycollectionfield
    self.assertEqual(obj.geom, geom)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^
AssertionError: <GeometryCollection object at 0x782e1769da40> != <GeometryCollection object at 0x782e1769dea0>
```

> [USER_REDACTED] ^^ can you speculate on the cause offhand? Some way that Oracle normalizes geometries perhaps?

TBH, I have no idea, but I can try to investigate it.

It seems that the database is reversing the direction of the polygon:

> (Pdb) geom.wkt
'GEOMETRYCOLLECTION (POINT (2 2), LINESTRING (0 0, 2 2), POLYGON ((0 0, 0 5, 5 5, 5 0, 0 0)))'
(Pdb) obj.geom.wkt
'GEOMETRYCOLLECTION (POINT (2 2), LINESTRING (0 0, 2 2), POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0)))'

We could change the test to:

```python
        self.assertIs(obj.geom.equals(geom), True)
```

I think this is ok as there are other tests in the file which use `.equals()` method for comparing geometries?

Thank you, David.

Should this be singular and not plural? `GeometryCollections(geom=single_collection)` doesn't seem right, although I get `Lines(geom=lines)`.

I didn't want to collide with `geos.GeometryCollection`. Could go with `GeometryCollectionModel`.

That would work 👍🏻


Fixed #36680 -- Parametrized formatter discovery in AdminScriptTestCase.

ticket-36680

Alternative to #19990.

@@ -33,11 +33,13 @@
 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 @@

 SYSTEM_CHECK_MSG = "System check identified no issues"

-HAS_BLACK = shutil.which("black")
-

 class AdminScriptTestCase(SimpleTestCase):
     def setUp(self):
@@ -112,7 +112,20 @@ def _ext_backend_paths(self):
                 paths.append(os.path.dirname(backend_dir))
         return paths

-    def run_test(self, args, settings_file=None, apps=None, umask=-1):
+    [USER_REDACTED]_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 @@ def run_test(self, args, settings_file=None, apps=None, umask=-1):
         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 @@ def run_test(self, args, settings_file=None, apps=None, umask=-1):
         )
         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 @@ def run_manage(self, args, settings_file=None, manage_py=None):
         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 @@ def test_setup_environ(self):
         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 @@ def test_startapp_unicode_name(self):
         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 @@ def setUp(self):
     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 @@ def test_custom_project_template_non_python_files_not_formatted(self):
         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 @@ def serve_template(request, *args, **kwargs):
                 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 @@ def test_template(self):
         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 = [

Looks perfect, thank you! 🌟

Looks good, added a small question/suggestion.

LGTM! 🌟

If black exists in multiple places on the path, we'd have to iterate this algorithm to keep patching it out. Not sure it's worth it.

I agree it's not worth it. But perhaps we could/should remove from the `PATH` every formatter returned from `find_formatters` to be truth to the param name?
Suggestion below.

```suggestion
                path_component
                for path_component in os.environ.get("PATH", "").split(os.pathsep)
                for formmater_path in find_formatters().values()
                if os.path.commonpath([path_component, formatter_path]) == os.sep
```