summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClifford Gama <cliffygamy@gmail.com>2025-08-07 17:26:15 +0200
committerJacob Walls <jacobtylerwalls@gmail.com>2025-10-29 15:00:52 -0400
commitadc25a9a6696f7e2ab6181aabce3a23e027d6703 (patch)
tree56e3950209471cbf4b16633897ba98f4ebd552e3
parentab108bf94dfc06c311d7dc81866b848fe5b5ee6c (diff)
Fixed #35381 -- Added JSONNull() expression.
Thanks Jacob Walls for the review.
-rw-r--r--django/db/models/__init__.py3
-rw-r--r--django/db/models/fields/json.py23
-rw-r--r--docs/ref/models/expressions.txt12
-rw-r--r--docs/releases/6.1.txt6
-rw-r--r--docs/topics/db/queries.txt39
-rw-r--r--tests/model_fields/models.py16
-rw-r--r--tests/model_fields/test_jsonfield.py124
7 files changed, 213 insertions, 10 deletions
diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py
index 757e098317..65123c3e85 100644
--- a/django/db/models/__init__.py
+++ b/django/db/models/__init__.py
@@ -45,7 +45,7 @@ from django.db.models.fields import __all__ as fields_all
from django.db.models.fields.composite import CompositePrimaryKey
from django.db.models.fields.files import FileField, ImageField
from django.db.models.fields.generated import GeneratedField
-from django.db.models.fields.json import JSONField
+from django.db.models.fields.json import JSONField, JSONNull
from django.db.models.fields.proxy import OrderWrt
from django.db.models.indexes import * # NOQA
from django.db.models.indexes import __all__ as indexes_all
@@ -97,6 +97,7 @@ __all__ += [
"ExpressionWrapper",
"F",
"Func",
+ "JSONNull",
"OrderBy",
"OuterRef",
"RowRange",
diff --git a/django/db/models/fields/json.py b/django/db/models/fields/json.py
index af5ec4c8b0..16be6846ff 100644
--- a/django/db/models/fields/json.py
+++ b/django/db/models/fields/json.py
@@ -148,6 +148,27 @@ class JSONField(CheckFieldDefaultMixin, Field):
)
+class JSONNull(expressions.Value):
+ """Represent JSON `null` primitive."""
+
+ def __init__(self):
+ super().__init__(None, output_field=JSONField())
+
+ def __repr__(self):
+ return f"{self.__class__.__name__}()"
+
+ def as_sql(self, compiler, connection):
+ value = self.output_field.get_db_prep_value(self.value, connection)
+ if value is None:
+ value = "null"
+ return "%s", (value,)
+
+ def as_mysql(self, compiler, connection):
+ sql, params = self.as_sql(compiler, connection)
+ sql = "JSON_EXTRACT(%s, '$')"
+ return sql, params
+
+
class DataContains(FieldGetDbPrepValueMixin, PostgresOperatorLookup):
lookup_name = "contains"
postgres_operator = "@>"
@@ -318,7 +339,7 @@ class JSONExact(lookups.Exact):
# Treat None lookup values as null.
if rhs == "%s" and (*rhs_params,) == (None,):
rhs_params = ("null",)
- if connection.vendor == "mysql":
+ if connection.vendor == "mysql" and not isinstance(self.rhs, JSONNull):
func = ["JSON_EXTRACT(%s, '$')"] * len(rhs_params)
rhs %= tuple(func)
return rhs, rhs_params
diff --git a/docs/ref/models/expressions.txt b/docs/ref/models/expressions.txt
index fa5b4b9540..037d7520a3 100644
--- a/docs/ref/models/expressions.txt
+++ b/docs/ref/models/expressions.txt
@@ -575,6 +575,18 @@ available on other expressions. ``ExpressionWrapper`` is necessary when using
arithmetic on ``F()`` expressions with different types as described in
:ref:`using-f-with-annotations`.
+``JSONNull()`` expression
+-------------------------
+
+.. versionadded:: 6.1
+
+.. class:: JSONNull()
+
+Specialized expression to represent JSON scalar ``null`` on a
+:class:`~django.db.models.JSONField`.
+
+See :ref:`storing-and-querying-for-none` for usage examples.
+
Conditional expressions
-----------------------
diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt
index 670026077a..bb8f686f84 100644
--- a/docs/releases/6.1.txt
+++ b/docs/releases/6.1.txt
@@ -238,6 +238,12 @@ Models
* :meth:`.QuerySet.in_bulk` now supports chaining after
:meth:`.QuerySet.values` and :meth:`.QuerySet.values_list`.
+* The new :class:`~django.db.models.JSONNull` expression provides an explicit
+ way to represent the JSON scalar ``null``. It can be used when saving a
+ top-level :class:`~django.db.models.JSONField` value, or querying for
+ top-level or nested JSON ``null`` values. See
+ :ref:`storing-and-querying-for-none` for usage examples and some caveats.
+
Pagination
~~~~~~~~~~
diff --git a/docs/topics/db/queries.txt b/docs/topics/db/queries.txt
index f6b8717f58..788a418e4f 100644
--- a/docs/topics/db/queries.txt
+++ b/docs/topics/db/queries.txt
@@ -1048,13 +1048,15 @@ the following example model::
def __str__(self):
return self.name
+.. _storing-and-querying-for-none:
+
Storing and querying for ``None``
---------------------------------
As with other fields, storing ``None`` as the field's value will store it as
SQL ``NULL``. While not recommended, it is possible to store JSON scalar
-``null`` instead of SQL ``NULL`` by using :class:`Value(None, JSONField())
-<django.db.models.Value>`.
+``null`` instead of SQL ``NULL`` by using the :class:`JSONNull()
+<django.db.models.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 <isnull>` 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: Max>
- >>> Dog.objects.create(name="Archie", data=Value(None, JSONField())) # JSON null.
+ >>> Dog.objects.create(name="Archie", data=JSONNull()) # JSON null.
<Dog: Archie>
- >>> Dog.objects.filter(data=None)
- <QuerySet [<Dog: Archie>]>
- >>> Dog.objects.filter(data=Value(None, JSONField()))
+ >>> Dog.objects.filter(data=JSONNull())
<QuerySet [<Dog: Archie>]>
>>> Dog.objects.filter(data__isnull=True)
<QuerySet [<Dog: Max>]>
@@ -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
<django.db.models.Field.null>`.
+.. admonition:: Storing JSON ``null`` inside JSON data
+
+ While :class:`JSONNull() <django.db.models.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")
<QuerySet [<Dog: Meg>]>
+To query a key for JSON ``null``, ``None`` or :class:`JSONNull()
+<django.db.models.JSONNull>` can be used.
+
+.. code-block:: pycon
+
+ >>> Dog.objects.filter(data__owner=None)
+ <Dog: Meg>
+ >>> Dog.objects.filter(data__owner=JSONNull())
+ <Dog: Meg>
+
Multiple keys can be chained together to form a path lookup:
.. code-block:: pycon
diff --git a/tests/model_fields/models.py b/tests/model_fields/models.py
index ba8d4fa6b0..816e5a16ba 100644
--- a/tests/model_fields/models.py
+++ b/tests/model_fields/models.py
@@ -403,6 +403,13 @@ class CustomJSONDecoder(json.JSONDecoder):
return dct
+class JSONNullCustomEncoder(json.JSONEncoder):
+ def default(self, o):
+ if isinstance(o, models.JSONNull):
+ return None
+ return super().default(o)
+
+
class JSONModel(models.Model):
value = models.JSONField()
@@ -422,6 +429,15 @@ class NullableJSONModel(models.Model):
required_db_features = {"supports_json_field"}
+class JSONNullDefaultModel(models.Model):
+ value = models.JSONField(
+ db_default=models.JSONNull(), encoder=JSONNullCustomEncoder
+ )
+
+ class Meta:
+ required_db_features = {"supports_json_field"}
+
+
class RelatedJSONModel(models.Model):
value = models.JSONField()
json_model = models.ForeignKey(NullableJSONModel, models.CASCADE)
diff --git a/tests/model_fields/test_jsonfield.py b/tests/model_fields/test_jsonfield.py
index b16499d198..fd2a880f99 100644
--- a/tests/model_fields/test_jsonfield.py
+++ b/tests/model_fields/test_jsonfield.py
@@ -16,16 +16,20 @@ from django.db import (
transaction,
)
from django.db.models import (
+ Case,
+ CheckConstraint,
Count,
ExpressionWrapper,
F,
IntegerField,
JSONField,
+ JSONNull,
OuterRef,
Q,
Subquery,
Transform,
Value,
+ When,
)
from django.db.models.expressions import RawSQL
from django.db.models.fields.json import (
@@ -44,6 +48,7 @@ from .models import (
CustomJSONDecoder,
CustomSerializationJSONModel,
JSONModel,
+ JSONNullDefaultModel,
NullableJSONModel,
RelatedJSONModel,
)
@@ -1241,3 +1246,122 @@ class TestQuerying(TestCase):
data__foo="bar"
)
self.assertQuerySetEqual(qs, all_objects)
+
+
+@skipUnlessDBFeature("supports_primitives_in_json_field")
+class JSONNullTests(TestCase):
+ def test_repr(self):
+ self.assertEqual(repr(JSONNull()), "JSONNull()")
+
+ def test_save_load(self):
+ obj = JSONModel(value=JSONNull())
+ obj.save()
+ self.assertIsNone(obj.value)
+
+ def test_create(self):
+ obj = JSONModel.objects.create(value=JSONNull())
+ self.assertIsNone(obj.value)
+
+ def test_update(self):
+ obj = JSONModel.objects.create(value={"key": "value"})
+ JSONModel.objects.update(value=JSONNull())
+ obj.refresh_from_db()
+ self.assertIsNone(obj.value)
+
+ def test_filter(self):
+ json_null = NullableJSONModel.objects.create(value=JSONNull())
+ sql_null = NullableJSONModel.objects.create(value=None)
+ self.assertSequenceEqual(
+ [json_null], NullableJSONModel.objects.filter(value=JSONNull())
+ )
+ self.assertSequenceEqual(
+ NullableJSONModel.objects.filter(value__isnull=True), [sql_null]
+ )
+
+ def test_bulk_update(self):
+ obj1 = NullableJSONModel.objects.create(value={"k": "1st"})
+ obj2 = NullableJSONModel.objects.create(value={"k": "2nd"})
+ obj1.value = JSONNull()
+ obj2.value = JSONNull()
+ NullableJSONModel.objects.bulk_update([obj1, obj2], fields=["value"])
+ self.assertSequenceEqual(
+ NullableJSONModel.objects.filter(value=JSONNull()),
+ [obj1, obj2],
+ )
+
+ def test_case_expression_with_jsonnull_then(self):
+ obj = JSONModel.objects.create(value={"key": "value"})
+ JSONModel.objects.filter(pk=obj.pk).update(
+ value=Case(
+ When(value={"key": "value"}, then=JSONNull()),
+ )
+ )
+ obj.refresh_from_db()
+ self.assertIsNone(obj.value)
+
+ def test_case_expr_with_jsonnull_condition(self):
+ obj = NullableJSONModel.objects.create(value=JSONNull())
+ NullableJSONModel.objects.filter(pk=obj.pk).update(
+ value=Case(
+ When(
+ value=JSONNull(),
+ then=Value({"key": "replaced"}, output_field=JSONField()),
+ )
+ ),
+ )
+ obj.refresh_from_db()
+ self.assertEqual(obj.value, {"key": "replaced"})
+
+ def test_key_transform_exact_filter(self):
+ obj = NullableJSONModel.objects.create(value={"key": None})
+ self.assertSequenceEqual(
+ NullableJSONModel.objects.filter(value__key=JSONNull()),
+ [obj],
+ )
+ self.assertSequenceEqual(
+ NullableJSONModel.objects.filter(value__key=None), [obj]
+ )
+
+ def test_index_lookup(self):
+ obj = NullableJSONModel.objects.create(value=["a", "b", None, 3])
+ self.assertSequenceEqual(
+ NullableJSONModel.objects.filter(value__2=JSONNull()), [obj]
+ )
+ self.assertSequenceEqual(NullableJSONModel.objects.filter(value__2=None), [obj])
+
+ @skipUnlessDBFeature("supports_table_check_constraints")
+ def test_constraint_validation(self):
+ constraint = CheckConstraint(
+ condition=~Q(value=JSONNull()), name="check_not_json_null"
+ )
+ constraint.validate(NullableJSONModel, NullableJSONModel(value={"key": None}))
+ msg = f"Constraint “{constraint.name}” is violated."
+ with self.assertRaisesMessage(ValidationError, msg):
+ constraint.validate(NullableJSONModel, NullableJSONModel(value=JSONNull()))
+
+ @skipUnlessDBFeature("supports_table_check_constraints")
+ def test_constraint_validation_key_transform(self):
+ constraint = CheckConstraint(
+ condition=Q(value__has_key="name") & ~Q(value__name=JSONNull()),
+ name="check_value_name_not_json_null",
+ )
+ constraint.validate(
+ NullableJSONModel, NullableJSONModel(value={"name": "Django"})
+ )
+ msg = f"Constraint “{constraint.name}” is violated."
+ with self.assertRaisesMessage(ValidationError, msg):
+ constraint.validate(
+ NullableJSONModel, NullableJSONModel(value={"name": None})
+ )
+
+ def test_default(self):
+ obj = JSONNullDefaultModel.objects.create()
+ self.assertIsNone(obj.value)
+
+ def test_custom_jsonnull_encoder(self):
+ obj = JSONNullDefaultModel.objects.create(
+ value={"name": JSONNull(), "array": [1, JSONNull()]}
+ )
+ obj.refresh_from_db()
+ self.assertIsNone(obj.value["name"])
+ self.assertEqual(obj.value["array"], [1, None])