From adc25a9a6696f7e2ab6181aabce3a23e027d6703 Mon Sep 17 00:00:00 2001 From: Clifford Gama Date: Thu, 7 Aug 2025 17:26:15 +0200 Subject: [PATCH 1/4] 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 757e09831728..65123c3e85ff 100644 --- a/django/db/models/__init__.py +++ b/django/db/models/__init__.py @@ -45,7 +45,7 @@ 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 @@ "ExpressionWrapper", "F", "Func", + "JSONNull", "OrderBy", "OuterRef", "RowRange", diff --git a/django/db/models/fields/json.py b/django/db/models/fields/json.py index af5ec4c8b058..16be6846ff88 100644 --- a/django/db/models/fields/json.py +++ b/django/db/models/fields/json.py @@ -148,6 +148,27 @@ def formfield(self, **kwargs): ) +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 @@ def process_rhs(self, compiler, connection): # 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 fa5b4b9540d1..037d7520a380 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 670026077acb..bb8f686f842b 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 f6b8717f5801..788a418e4f02 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 ba8d4fa6b05c..816e5a16bad9 100644 --- a/tests/model_fields/models.py +++ b/tests/model_fields/models.py @@ -403,6 +403,13 @@ def as_uuid(self, dct): 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 Meta: 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 b16499d1980b..fd2a880f99c4 100644 --- a/tests/model_fields/test_jsonfield.py +++ b/tests/model_fields/test_jsonfield.py @@ -16,16 +16,20 @@ 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 @@ CustomJSONDecoder, CustomSerializationJSONModel, JSONModel, + JSONNullDefaultModel, NullableJSONModel, RelatedJSONModel, ) @@ -1241,3 +1246,122 @@ def test_literal_annotation_filtering(self): 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 2/4] 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 078428416c5a..a76598a9bff4 100644 --- a/django/contrib/postgres/fields/array.py +++ b/django/contrib/postgres/fields/array.py @@ -135,6 +135,11 @@ def get_db_prep_value(self, value, connection, prepared=False): ] 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 bb8f686f842b..412ec692e30c 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 f07f4492b8c2..6a3d25a6af49 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 392b8f946cb4..e65009ad83d5 100644 --- a/tests/postgres_tests/test_array.py +++ b/tests/postgres_tests/test_array.py @@ -10,6 +10,7 @@ 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 @@ def test_array_with_choices_display_for_field(self): 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 3/4] 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 16be6846ff88..819c87119ab8 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 @@ 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 @@ def process_rhs(self, compiler, connection): 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 @@ def resolve_expression_parameter(self, compiler, connection, sql, param): 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 412ec692e30c..dba26cca05d4 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 788a418e4f02..b3b6ec125d75 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 fd2a880f99c4..937b55779422 100644 --- a/tests/model_fields/test_jsonfield.py +++ b/tests/model_fields/test_jsonfield.py @@ -41,8 +41,15 @@ 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 @@ def test_null(self): 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 @@ def test_json_null_different_from_sql_null(self): ) self.assertSequenceEqual( NullableJSONModel.objects.filter(value=None), + # RemovedInDjango70Warning: When the deprecation ends, replace + # with: + # [sql_null], [json_null], ) self.assertSequenceEqual( @@ -1365,3 +1377,36 @@ def test_custom_jsonnull_encoder(self): 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 e65009ad83d5..f35211e8ed75 100644 --- a/tests/postgres_tests/test_array.py +++ b/tests/postgres_tests/test_array.py @@ -16,6 +16,7 @@ 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 @@ def test_saving_and_querying_for_sql_null(self): 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 4/4] 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 e494739bcd46..9bfaea025d5e 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::