From 348ca845385beaddc7c862ff8ec369f041a5088d Mon Sep 17 00:00:00 2001 From: Clifford Gama Date: Fri, 24 Oct 2025 23:38:52 +0200 Subject: [PATCH] Refs #35381 -- Deprecated using None in JSONExact rhs to mean JSON null. Key and index lookups are exempt from the deprecation. Co-authored-by: Jacob Walls --- django/db/models/fields/json.py | 20 ++++++++++++ docs/releases/6.1.txt | 7 +++++ docs/topics/db/queries.txt | 14 +++++++++ tests/model_fields/test_jsonfield.py | 47 +++++++++++++++++++++++++++- tests/postgres_tests/test_array.py | 12 +++++++ 5 files changed, 99 insertions(+), 1 deletion(-) diff --git a/django/db/models/fields/json.py b/django/db/models/fields/json.py index 16be6846ff..819c87119a 100644 --- a/django/db/models/fields/json.py +++ b/django/db/models/fields/json.py @@ -1,4 +1,5 @@ import json +import warnings from django import forms from django.core import checks, exceptions @@ -11,6 +12,7 @@ from django.db.models.lookups import ( PostgresOperatorLookup, Transform, ) +from django.utils.deprecation import RemovedInDjango70Warning, django_file_prefixes from django.utils.translation import gettext_lazy as _ from . import Field @@ -332,10 +334,24 @@ class CaseInsensitiveMixin: class JSONExact(lookups.Exact): + # RemovedInDjango70Warning: When the deprecation period is over, remove + # the following line. can_use_none_as_rhs = True def process_rhs(self, compiler, connection): + if self.rhs is None and not isinstance(self.lhs, KeyTransform): + warnings.warn( + "Using None as the right-hand side of an exact lookup on JSONField to " + "mean JSON scalar 'null' is deprecated. Use JSONNull() instead (or use " + "the __isnull lookup if you meant SQL NULL).", + RemovedInDjango70Warning, + skip_file_prefixes=django_file_prefixes(), + ) + rhs, rhs_params = super().process_rhs(compiler, connection) + + # RemovedInDjango70Warning: When the deprecation period is over, remove + # The following if-block entirely. # Treat None lookup values as null. if rhs == "%s" and (*rhs_params,) == (None,): rhs_params = ("null",) @@ -547,6 +563,10 @@ class KeyTransformIn(lookups.In): class KeyTransformExact(JSONExact): + # RemovedInDjango70Warning: When deprecation period ends, uncomment the + # flag below. + # can_use_none_as_rhs = True + def process_rhs(self, compiler, connection): if isinstance(self.rhs, KeyTransform): return super(lookups.Exact, self).process_rhs(compiler, connection) diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 412ec692e3..dba26cca05 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -360,6 +360,13 @@ Miscellaneous is deprecated. Pass an explicit field name, like ``values_list("pk", flat=True)``. +* The use of ``None`` to represent a top-level JSON scalar ``null`` when + querying :class:`~django.db.models.JSONField` is now deprecated in favor of + the new :class:`~django.db.models.JSONNull` expression. At the end + of the deprecation period, ``None`` values compile to SQL ``IS NULL`` when + used as the top-level value. :lookup:`Key and index lookups ` + are unaffected by this deprecation. + Features removed in 6.1 ======================= diff --git a/docs/topics/db/queries.txt b/docs/topics/db/queries.txt index 788a418e4f..b3b6ec125d 100644 --- a/docs/topics/db/queries.txt +++ b/docs/topics/db/queries.txt @@ -1069,6 +1069,11 @@ as JSON ``null``. When querying, :lookup:`isnull=True ` is used to match SQL ``NULL``, while exact-matching ``JSONNull()`` is used to match JSON ``null``. +.. deprecated:: 6.1 + + Exact-matching ``None`` in a query to mean JSON ``null`` is deprecated. + After the deprecation period, it will be interpreted as SQL ``NULL``. + .. versionchanged:: 6.1 ``JSONNull()`` expression was added. @@ -1080,6 +1085,12 @@ while exact-matching ``JSONNull()`` is used to match JSON ``null``. >>> Dog.objects.create(name="Archie", data=JSONNull()) # JSON null. + >>> Dog.objects.filter(data=None) + ...: RemovedInDjango70Warning: Using None as the right-hand side of an + exact lookup on JSONField to mean JSON scalar 'null' is deprecated. Use + JSONNull() instead (or use the __isnull lookup if you meant SQL NULL). + ... + ]> >>> Dog.objects.filter(data=JSONNull()) ]> >>> Dog.objects.filter(data__isnull=True) @@ -1087,6 +1098,9 @@ while exact-matching ``JSONNull()`` is used to match JSON ``null``. >>> Dog.objects.filter(data__isnull=False) ]> +.. RemovedInDjango70Warning: Alter the example with the deprecation warning to: + ]>. + Unless you are sure you wish to work with SQL ``NULL`` values, consider setting ``null=False`` and providing a suitable default for empty values, such as ``default=dict``. diff --git a/tests/model_fields/test_jsonfield.py b/tests/model_fields/test_jsonfield.py index fd2a880f99..937b557794 100644 --- a/tests/model_fields/test_jsonfield.py +++ b/tests/model_fields/test_jsonfield.py @@ -41,8 +41,15 @@ from django.db.models.fields.json import ( KeyTransformTextLookupMixin, ) from django.db.models.functions import Cast -from django.test import SimpleTestCase, TestCase, skipIfDBFeature, skipUnlessDBFeature +from django.test import ( + SimpleTestCase, + TestCase, + ignore_warnings, + skipIfDBFeature, + skipUnlessDBFeature, +) from django.test.utils import CaptureQueriesContext +from django.utils.deprecation import RemovedInDjango70Warning from .models import ( CustomJSONDecoder, @@ -229,6 +236,8 @@ class TestSaveLoad(TestCase): self.assertIsNone(obj.value) @skipUnlessDBFeature("supports_primitives_in_json_field") + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) def test_json_null_different_from_sql_null(self): json_null = NullableJSONModel.objects.create(value=Value(None, JSONField())) NullableJSONModel.objects.update(value=Value(None, JSONField())) @@ -242,6 +251,9 @@ class TestSaveLoad(TestCase): ) self.assertSequenceEqual( NullableJSONModel.objects.filter(value=None), + # RemovedInDjango70Warning: When the deprecation ends, replace + # with: + # [sql_null], [json_null], ) self.assertSequenceEqual( @@ -1365,3 +1377,36 @@ class JSONNullTests(TestCase): obj.refresh_from_db() self.assertIsNone(obj.value["name"]) self.assertEqual(obj.value["array"], [1, None]) + + +# RemovedInDjango70Warning. +@skipUnlessDBFeature("supports_primitives_in_json_field") +class JSONExactNoneDeprecationTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.msg = ( + "Using None as the right-hand side of an exact lookup on JSONField to mean " + "JSON scalar 'null' is deprecated. Use JSONNull() instead (or use the " + "__isnull lookup if you meant SQL NULL)." + ) + cls.obj = NullableJSONModel.objects.create(value=JSONNull()) + + def test_filter(self): + with self.assertWarnsMessage(RemovedInDjango70Warning, self.msg): + self.assertSequenceEqual( + NullableJSONModel.objects.filter(value=None), [self.obj] + ) + + def test_annotation_q_filter(self): + qs = NullableJSONModel.objects.annotate( + has_empty_data=Q(value__isnull=True) | Q(value=None) + ).filter(has_empty_data=True) + with self.assertWarnsMessage(RemovedInDjango70Warning, self.msg): + self.assertSequenceEqual(qs, [self.obj]) + + def test_case_when(self): + qs = NullableJSONModel.objects.annotate( + has_json_null=Case(When(value=None, then=Value(True)), default=Value(False)) + ).filter(has_json_null=True) + with self.assertWarnsMessage(RemovedInDjango70Warning, self.msg): + self.assertSequenceEqual(qs, [self.obj]) diff --git a/tests/postgres_tests/test_array.py b/tests/postgres_tests/test_array.py index e65009ad83..f35211e8ed 100644 --- a/tests/postgres_tests/test_array.py +++ b/tests/postgres_tests/test_array.py @@ -16,6 +16,7 @@ from django.db.models.functions import Cast, JSONObject, Upper from django.test import TransactionTestCase, override_settings, skipUnlessDBFeature from django.test.utils import isolate_apps from django.utils import timezone +from django.utils.deprecation import RemovedInDjango70Warning from . import PostgreSQLSimpleTestCase, PostgreSQLTestCase, PostgreSQLWidgetTestCase from .models import ( @@ -1586,6 +1587,17 @@ class TestJSONFieldQuerying(PostgreSQLTestCase): self.assertSequenceEqual( OtherTypesArrayModel.objects.filter(json__1__isnull=True), [obj] ) + # RemovedInDjango70Warning. + msg = ( + "Using None as the right-hand side of an exact lookup on JSONField to mean " + "JSON scalar 'null' is deprecated. Use JSONNull() instead (or use the " + "__isnull lookup if you meant SQL NULL)." + ) + with self.assertWarnsMessage(RemovedInDjango70Warning, msg): + # RemovedInDjango70Warning: deindent, and replace [] with [obj]. + self.assertSequenceEqual( + OtherTypesArrayModel.objects.filter(json__1=None), [] + ) def test_saving_and_querying_for_json_null(self): obj = OtherTypesArrayModel.objects.create(json=[JSONNull(), JSONNull()])