mirror of
https://github.com/django/django.git
synced 2025-11-07 07:15:35 +00:00
Fixed #35381 -- Added JSONNull() expression.
Thanks Jacob Walls for the review.
This commit is contained in:
committed by
Jacob Walls
parent
ab108bf94d
commit
adc25a9a66
@@ -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.composite import CompositePrimaryKey
|
||||||
from django.db.models.fields.files import FileField, ImageField
|
from django.db.models.fields.files import FileField, ImageField
|
||||||
from django.db.models.fields.generated import GeneratedField
|
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.fields.proxy import OrderWrt
|
||||||
from django.db.models.indexes import * # NOQA
|
from django.db.models.indexes import * # NOQA
|
||||||
from django.db.models.indexes import __all__ as indexes_all
|
from django.db.models.indexes import __all__ as indexes_all
|
||||||
@@ -97,6 +97,7 @@ __all__ += [
|
|||||||
"ExpressionWrapper",
|
"ExpressionWrapper",
|
||||||
"F",
|
"F",
|
||||||
"Func",
|
"Func",
|
||||||
|
"JSONNull",
|
||||||
"OrderBy",
|
"OrderBy",
|
||||||
"OuterRef",
|
"OuterRef",
|
||||||
"RowRange",
|
"RowRange",
|
||||||
|
|||||||
@@ -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):
|
class DataContains(FieldGetDbPrepValueMixin, PostgresOperatorLookup):
|
||||||
lookup_name = "contains"
|
lookup_name = "contains"
|
||||||
postgres_operator = "@>"
|
postgres_operator = "@>"
|
||||||
@@ -318,7 +339,7 @@ class JSONExact(lookups.Exact):
|
|||||||
# Treat None lookup values as null.
|
# Treat None lookup values as null.
|
||||||
if rhs == "%s" and (*rhs_params,) == (None,):
|
if rhs == "%s" and (*rhs_params,) == (None,):
|
||||||
rhs_params = ("null",)
|
rhs_params = ("null",)
|
||||||
if connection.vendor == "mysql":
|
if connection.vendor == "mysql" and not isinstance(self.rhs, JSONNull):
|
||||||
func = ["JSON_EXTRACT(%s, '$')"] * len(rhs_params)
|
func = ["JSON_EXTRACT(%s, '$')"] * len(rhs_params)
|
||||||
rhs %= tuple(func)
|
rhs %= tuple(func)
|
||||||
return rhs, rhs_params
|
return rhs, rhs_params
|
||||||
|
|||||||
@@ -575,6 +575,18 @@ available on other expressions. ``ExpressionWrapper`` is necessary when using
|
|||||||
arithmetic on ``F()`` expressions with different types as described in
|
arithmetic on ``F()`` expressions with different types as described in
|
||||||
:ref:`using-f-with-annotations`.
|
: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
|
Conditional expressions
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
|
|||||||
@@ -238,6 +238,12 @@ Models
|
|||||||
* :meth:`.QuerySet.in_bulk` now supports chaining after
|
* :meth:`.QuerySet.in_bulk` now supports chaining after
|
||||||
:meth:`.QuerySet.values` and :meth:`.QuerySet.values_list`.
|
: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
|
Pagination
|
||||||
~~~~~~~~~~
|
~~~~~~~~~~
|
||||||
|
|
||||||
|
|||||||
@@ -1048,13 +1048,15 @@ the following example model::
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
.. _storing-and-querying-for-none:
|
||||||
|
|
||||||
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
|
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
|
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()
|
||||||
<django.db.models.Value>`.
|
<django.db.models.JSONNull>` expression.
|
||||||
|
|
||||||
Whichever of the values is stored, when retrieved from the database, the Python
|
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.
|
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
|
is inside a :class:`list` or :class:`dict`, it will always be interpreted
|
||||||
as JSON ``null``.
|
as JSON ``null``.
|
||||||
|
|
||||||
When querying, ``None`` value will always be interpreted as JSON ``null``. To
|
When querying, :lookup:`isnull=True <isnull>` is used to match SQL ``NULL``,
|
||||||
query for SQL ``NULL``, use :lookup:`isnull`:
|
while exact-matching ``JSONNull()`` is used to match JSON ``null``.
|
||||||
|
|
||||||
|
.. versionchanged:: 6.1
|
||||||
|
|
||||||
|
``JSONNull()`` expression was added.
|
||||||
|
|
||||||
.. code-block:: pycon
|
.. code-block:: pycon
|
||||||
|
|
||||||
|
>>> from django.db.models import JSONNull
|
||||||
>>> Dog.objects.create(name="Max", data=None) # SQL NULL.
|
>>> Dog.objects.create(name="Max", data=None) # SQL NULL.
|
||||||
<Dog: Max>
|
<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: Archie>
|
||||||
>>> Dog.objects.filter(data=None)
|
>>> Dog.objects.filter(data=JSONNull())
|
||||||
<QuerySet [<Dog: Archie>]>
|
|
||||||
>>> Dog.objects.filter(data=Value(None, JSONField()))
|
|
||||||
<QuerySet [<Dog: Archie>]>
|
<QuerySet [<Dog: Archie>]>
|
||||||
>>> Dog.objects.filter(data__isnull=True)
|
>>> Dog.objects.filter(data__isnull=True)
|
||||||
<QuerySet [<Dog: Max>]>
|
<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
|
Storing JSON scalar ``null`` does not violate :attr:`null=False
|
||||||
<django.db.models.Field.null>`.
|
<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
|
.. fieldlookup:: jsonfield.key
|
||||||
|
|
||||||
.. _key-index-and-path-transforms:
|
.. _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")
|
>>> Dog.objects.filter(data__breed="collie")
|
||||||
<QuerySet [<Dog: Meg>]>
|
<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:
|
Multiple keys can be chained together to form a path lookup:
|
||||||
|
|
||||||
.. code-block:: pycon
|
.. code-block:: pycon
|
||||||
|
|||||||
@@ -403,6 +403,13 @@ class CustomJSONDecoder(json.JSONDecoder):
|
|||||||
return 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):
|
class JSONModel(models.Model):
|
||||||
value = models.JSONField()
|
value = models.JSONField()
|
||||||
|
|
||||||
@@ -422,6 +429,15 @@ class NullableJSONModel(models.Model):
|
|||||||
required_db_features = {"supports_json_field"}
|
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):
|
class RelatedJSONModel(models.Model):
|
||||||
value = models.JSONField()
|
value = models.JSONField()
|
||||||
json_model = models.ForeignKey(NullableJSONModel, models.CASCADE)
|
json_model = models.ForeignKey(NullableJSONModel, models.CASCADE)
|
||||||
|
|||||||
@@ -16,16 +16,20 @@ from django.db import (
|
|||||||
transaction,
|
transaction,
|
||||||
)
|
)
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
|
Case,
|
||||||
|
CheckConstraint,
|
||||||
Count,
|
Count,
|
||||||
ExpressionWrapper,
|
ExpressionWrapper,
|
||||||
F,
|
F,
|
||||||
IntegerField,
|
IntegerField,
|
||||||
JSONField,
|
JSONField,
|
||||||
|
JSONNull,
|
||||||
OuterRef,
|
OuterRef,
|
||||||
Q,
|
Q,
|
||||||
Subquery,
|
Subquery,
|
||||||
Transform,
|
Transform,
|
||||||
Value,
|
Value,
|
||||||
|
When,
|
||||||
)
|
)
|
||||||
from django.db.models.expressions import RawSQL
|
from django.db.models.expressions import RawSQL
|
||||||
from django.db.models.fields.json import (
|
from django.db.models.fields.json import (
|
||||||
@@ -44,6 +48,7 @@ from .models import (
|
|||||||
CustomJSONDecoder,
|
CustomJSONDecoder,
|
||||||
CustomSerializationJSONModel,
|
CustomSerializationJSONModel,
|
||||||
JSONModel,
|
JSONModel,
|
||||||
|
JSONNullDefaultModel,
|
||||||
NullableJSONModel,
|
NullableJSONModel,
|
||||||
RelatedJSONModel,
|
RelatedJSONModel,
|
||||||
)
|
)
|
||||||
@@ -1241,3 +1246,122 @@ class TestQuerying(TestCase):
|
|||||||
data__foo="bar"
|
data__foo="bar"
|
||||||
)
|
)
|
||||||
self.assertQuerySetEqual(qs, all_objects)
|
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])
|
||||||
|
|||||||
Reference in New Issue
Block a user