Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions django/contrib/postgres/fields/array.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
3 changes: 2 additions & 1 deletion django/db/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,6 +97,7 @@
"ExpressionWrapper",
"F",
"Func",
"JSONNull",
"OrderBy",
"OuterRef",
"RowRange",
Expand Down
43 changes: 42 additions & 1 deletion django/db/models/fields/json.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import warnings

from django import forms
from django.core import checks, exceptions
Expand All @@ -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
Expand Down Expand Up @@ -148,6 +150,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 = "@>"
Expand Down Expand Up @@ -311,14 +334,28 @@ 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",)
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
Expand Down Expand Up @@ -526,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)
Expand Down
12 changes: 12 additions & 0 deletions docs/ref/models/expressions.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----------------------

Expand Down
2 changes: 2 additions & 0 deletions docs/ref/models/querysets.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jsonfield.key>` are exceptions: they
interpret ``None`` as JSON ``null`` instead.

Examples::

Expand Down
22 changes: 22 additions & 0 deletions docs/releases/6.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~

Expand Down Expand Up @@ -313,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
---------------------------------

Expand Down Expand Up @@ -345,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 <jsonfield.key>`
are unaffected by this deprecation.

Features removed in 6.1
=======================

Expand Down
49 changes: 43 additions & 6 deletions docs/topics/db/queries.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -1064,24 +1066,41 @@ 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``.

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

.. 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)
...: 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).
...
<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>]>
>>> Dog.objects.filter(data__isnull=False)
<QuerySet [<Dog: Archie>]>

.. RemovedInDjango70Warning: Alter the example with the deprecation warning to:
<QuerySet [<Dog: Max>]>.

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``.
Expand All @@ -1091,6 +1110,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:
Expand Down Expand Up @@ -1122,6 +1149,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
Expand Down
16 changes: 16 additions & 0 deletions tests/model_fields/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
Expand Down
Loading
Loading