Skip to content

Commit

Permalink
Refs #33308 -- Deprecated support for passing encoded JSON string lit…
Browse files Browse the repository at this point in the history
…erals to JSONField & co.

JSON should be provided as literal Python objects an not in their
encoded string literal forms.
  • Loading branch information
charettes authored and felixxm committed Dec 1, 2022
1 parent d3e746a commit 0ff4659
Show file tree
Hide file tree
Showing 11 changed files with 230 additions and 30 deletions.
52 changes: 46 additions & 6 deletions django/contrib/postgres/aggregates/general.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import json
import warnings

from django.contrib.postgres.fields import ArrayField
from django.db.models import Aggregate, BooleanField, JSONField, TextField, Value
from django.utils.deprecation import RemovedInDjango50Warning
from django.utils.deprecation import RemovedInDjango50Warning, RemovedInDjango51Warning

from .mixins import OrderableAggMixin

Expand Down Expand Up @@ -31,6 +32,14 @@ def __init__(self, *expressions, default=NOT_PROVIDED, **extra):
self._default_provided = True
super().__init__(*expressions, default=default, **extra)

def resolve_expression(self, *args, **kwargs):
resolved = super().resolve_expression(*args, **kwargs)
if not self._default_provided:
resolved.empty_result_set_value = getattr(
self, "deprecation_empty_result_set_value", self.deprecation_value
)
return resolved

def convert_value(self, value, expression, connection):
if value is None and not self._default_provided:
warnings.warn(self.deprecation_msg, category=RemovedInDjango50Warning)
Expand All @@ -48,8 +57,7 @@ class ArrayAgg(DeprecatedConvertValueMixin, OrderableAggMixin, Aggregate):
deprecation_msg = (
"In Django 5.0, ArrayAgg() will return None instead of an empty list "
"if there are no rows. Pass default=None to opt into the new behavior "
"and silence this warning or default=Value([]) to keep the previous "
"behavior."
"and silence this warning or default=[] to keep the previous behavior."
)

@property
Expand Down Expand Up @@ -87,13 +95,46 @@ class JSONBAgg(DeprecatedConvertValueMixin, OrderableAggMixin, Aggregate):

# RemovedInDjango50Warning
deprecation_value = "[]"
deprecation_empty_result_set_value = property(lambda self: [])
deprecation_msg = (
"In Django 5.0, JSONBAgg() will return None instead of an empty list "
"if there are no rows. Pass default=None to opt into the new behavior "
"and silence this warning or default=Value('[]') to keep the previous "
"and silence this warning or default=[] to keep the previous "
"behavior."
)

# RemovedInDjango51Warning: When the deprecation ends, remove __init__().
#
# RemovedInDjango50Warning: When the deprecation ends, replace with:
# def __init__(self, *expressions, default=None, **extra):
def __init__(self, *expressions, default=NOT_PROVIDED, **extra):
super().__init__(*expressions, default=default, **extra)
if (
isinstance(default, Value)
and isinstance(default.value, str)
and not isinstance(default.output_field, JSONField)
):
value = default.value
try:
decoded = json.loads(value)
except json.JSONDecodeError:
warnings.warn(
"Passing a Value() with an output_field that isn't a JSONField as "
"JSONBAgg(default) is deprecated. Pass default="
f"Value({value!r}, output_field=JSONField()) instead.",
stacklevel=2,
category=RemovedInDjango51Warning,
)
self.default.output_field = self.output_field
else:
self.default = Value(decoded, self.output_field)
warnings.warn(
"Passing an encoded JSON string as JSONBAgg(default) is "
f"deprecated. Pass default={decoded!r} instead.",
stacklevel=2,
category=RemovedInDjango51Warning,
)


class StringAgg(DeprecatedConvertValueMixin, OrderableAggMixin, Aggregate):
function = "STRING_AGG"
Expand All @@ -106,8 +147,7 @@ class StringAgg(DeprecatedConvertValueMixin, OrderableAggMixin, Aggregate):
deprecation_msg = (
"In Django 5.0, StringAgg() will return None instead of an empty "
"string if there are no rows. Pass default=None to opt into the new "
"behavior and silence this warning or default=Value('') to keep the "
"previous behavior."
'behavior and silence this warning or default="" to keep the previous behavior.'
)

def __init__(self, expression, delimiter, **extra):
Expand Down
2 changes: 2 additions & 0 deletions django/db/models/aggregates.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ def resolve_expression(
return c
if hasattr(default, "resolve_expression"):
default = default.resolve_expression(query, allow_joins, reuse, summarize)
if default._output_field_or_none is None:
default.output_field = c._output_field_or_none
else:
default = Value(default, c._output_field_or_none)
c.default = None # Reset the default argument before wrapping.
Expand Down
4 changes: 4 additions & 0 deletions django/db/models/fields/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,8 @@ def get_db_prep_value(self, value, connection, prepared=False):

def get_db_prep_save(self, value, connection):
"""Return field's value prepared for saving into a database."""
if hasattr(value, "as_sql"):
return value
return self.get_db_prep_value(value, connection=connection, prepared=False)

def has_default(self):
Expand Down Expand Up @@ -1715,6 +1717,8 @@ def to_python(self, value):
def get_db_prep_value(self, value, connection, prepared=False):
if not prepared:
value = self.get_prep_value(value)
if hasattr(value, "as_sql"):
return value
return connection.ops.adapt_decimalfield_value(
value, self.max_digits, self.decimal_places
)
Expand Down
31 changes: 29 additions & 2 deletions django/db/models/fields/json.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import json
import warnings

from django import forms
from django.core import checks, exceptions
from django.db import NotSupportedError, connections, router
from django.db.models import lookups
from django.db.models import expressions, lookups
from django.db.models.constants import LOOKUP_SEP
from django.db.models.fields import TextField
from django.db.models.lookups import (
FieldGetDbPrepValueMixin,
PostgresOperatorLookup,
Transform,
)
from django.utils.deprecation import RemovedInDjango51Warning
from django.utils.translation import gettext_lazy as _

from . import Field
Expand Down Expand Up @@ -97,7 +99,32 @@ def get_internal_type(self):
return "JSONField"

def get_db_prep_value(self, value, connection, prepared=False):
if hasattr(value, "as_sql"):
# RemovedInDjango51Warning: When the deprecation ends, replace with:
# if (
# isinstance(value, expressions.Value)
# and isinstance(value.output_field, JSONField)
# ):
# value = value.value
# elif hasattr(value, "as_sql"): ...
if isinstance(value, expressions.Value):
if isinstance(value.value, str) and not isinstance(
value.output_field, JSONField
):
try:
value = json.loads(value.value, cls=self.decoder)
except json.JSONDecodeError:
value = value.value
else:
warnings.warn(
"Providing an encoded JSON string via Value() is deprecated. "
f"Use Value({value!r}, output_field=JSONField()) instead.",
category=RemovedInDjango51Warning,
)
elif isinstance(value.output_field, JSONField):
value = value.value
else:
return value
elif hasattr(value, "as_sql"):
return value
return connection.ops.adapt_json_value(value, self.encoder)

Expand Down
12 changes: 3 additions & 9 deletions django/db/models/sql/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1637,9 +1637,7 @@ def prepare_value(self, field, value):
"Window expressions are not allowed in this query (%s=%r)."
% (field.name, value)
)
else:
value = field.get_db_prep_save(value, connection=self.connection)
return value
return field.get_db_prep_save(value, connection=self.connection)

def pre_save_val(self, field, obj):
"""
Expand Down Expand Up @@ -1893,18 +1891,14 @@ def as_sql(self):
)
elif hasattr(val, "prepare_database_save"):
if field.remote_field:
val = field.get_db_prep_save(
val.prepare_database_save(field),
connection=self.connection,
)
val = val.prepare_database_save(field)
else:
raise TypeError(
"Tried to update field %s with a model instance, %r. "
"Use a value compatible with %s."
% (field, val, field.__class__.__name__)
)
else:
val = field.get_db_prep_save(val, connection=self.connection)
val = field.get_db_prep_save(val, connection=self.connection)

# Getting the placeholder for the field.
if hasattr(field, "get_placeholder"):
Expand Down
6 changes: 3 additions & 3 deletions django/db/models/sql/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,9 +522,9 @@ def get_aggregation(self, using, aggregate_exprs):
result = compiler.execute_sql(SINGLE)
if result is None:
result = empty_set_result

converters = compiler.get_converters(outer_query.annotation_select.values())
result = next(compiler.apply_converters((result,), converters))
else:
converters = compiler.get_converters(outer_query.annotation_select.values())
result = next(compiler.apply_converters((result,), converters))

return dict(zip(outer_query.annotation_select, result))

Expand Down
3 changes: 3 additions & 0 deletions docs/internals/deprecation.txt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ details on these changes.

* The ``TransactionTestCase.assertQuerysetEqual()`` method will be removed.

* Support for passing encoded JSON string literals to ``JSONField`` and
associated lookups and expressions will be removed.

.. _deprecation-removed-in-5.0:

5.0
Expand Down
36 changes: 36 additions & 0 deletions docs/releases/4.2.txt
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,42 @@ but it should not be used for new migrations. Use
:class:`~django.db.migrations.operations.AddIndex` and
:class:`~django.db.migrations.operations.RemoveIndex` operations instead.

Passing encoded JSON string literals to ``JSONField`` is deprecated
-------------------------------------------------------------------

``JSONField`` and its associated lookups and aggregates use to allow passing
JSON encoded string literals which caused ambiguity on whether string literals
were already encoded from database backend's perspective.

During the deprecation period string literals will be attempted to be JSON
decoded and a warning will be emitted on success that points at passing
non-encoded forms instead.

Code that use to pass JSON encoded string literals::

Document.objects.bulk_create(
Document(data=Value("null")),
Document(data=Value("[]")),
Document(data=Value('"foo-bar"')),
)
Document.objects.annotate(
JSONBAgg("field", default=Value('[]')),
)

Should become::

Document.objects.bulk_create(
Document(data=Value(None, JSONField())),
Document(data=[]),
Document(data="foo-bar"),
)
Document.objects.annotate(
JSONBAgg("field", default=[]),
)

From Django 5.1+ string literals will be implicitly interpreted as JSON string
literals.

Miscellaneous
-------------

Expand Down
17 changes: 14 additions & 3 deletions docs/topics/db/queries.txt
Original file line number Diff line number Diff line change
Expand Up @@ -971,7 +971,7 @@ 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('null')
``null`` instead of SQL ``NULL`` by using :class:`Value(None, JSONField())
<django.db.models.Value>`.

Whichever of the values is stored, when retrieved from the database, the Python
Expand All @@ -987,11 +987,13 @@ query for SQL ``NULL``, use :lookup:`isnull`::

>>> Dog.objects.create(name='Max', data=None) # SQL NULL.
<Dog: Max>
>>> Dog.objects.create(name='Archie', data=Value('null')) # JSON null.
>>> Dog.objects.create(
... name='Archie', data=Value(None, JSONField()) # JSON null.
... )
<Dog: Archie>
>>> Dog.objects.filter(data=None)
<QuerySet [<Dog: Archie>]>
>>> Dog.objects.filter(data=Value('null'))
>>> Dog.objects.filter(data=Value(None, JSONField())
<QuerySet [<Dog: Archie>]>
>>> Dog.objects.filter(data__isnull=True)
<QuerySet [<Dog: Max>]>
Expand All @@ -1007,6 +1009,15 @@ 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>`.

.. versionchanged:: 4.2

Support for expressing JSON ``null`` using ``Value(None, JSONField())`` was
added.

.. deprecated:: 4.2

Passing ``Value("null")`` to express JSON ``null`` is deprecated.

.. fieldlookup:: jsonfield.key

Key, index, and path transforms
Expand Down
31 changes: 29 additions & 2 deletions tests/model_fields/test_jsonfield.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
ExpressionWrapper,
F,
IntegerField,
JSONField,
OuterRef,
Q,
Subquery,
Expand All @@ -36,6 +37,7 @@
from django.db.models.functions import Cast
from django.test import SimpleTestCase, TestCase, skipIfDBFeature, skipUnlessDBFeature
from django.test.utils import CaptureQueriesContext
from django.utils.deprecation import RemovedInDjango51Warning

from .models import CustomJSONDecoder, JSONModel, NullableJSONModel, RelatedJSONModel

Expand Down Expand Up @@ -191,15 +193,40 @@ def test_null(self):
obj.refresh_from_db()
self.assertIsNone(obj.value)

def test_ambiguous_str_value_deprecation(self):
msg = (
"Providing an encoded JSON string via Value() is deprecated. Use Value([], "
"output_field=JSONField()) instead."
)
with self.assertWarnsMessage(RemovedInDjango51Warning, msg):
obj = NullableJSONModel.objects.create(value=Value("[]"))
obj.refresh_from_db()
self.assertEqual(obj.value, [])

@skipUnlessDBFeature("supports_primitives_in_json_field")
def test_value_str_primitives_deprecation(self):
msg = (
"Providing an encoded JSON string via Value() is deprecated. Use "
"Value(None, output_field=JSONField()) instead."
)
with self.assertWarnsMessage(RemovedInDjango51Warning, msg):
obj = NullableJSONModel.objects.create(value=Value("null"))
obj.refresh_from_db()
self.assertIsNone(obj.value)
obj = NullableJSONModel.objects.create(value=Value("invalid-json"))
obj.refresh_from_db()
self.assertEqual(obj.value, "invalid-json")

@skipUnlessDBFeature("supports_primitives_in_json_field")
def test_json_null_different_from_sql_null(self):
json_null = NullableJSONModel.objects.create(value=Value("null"))
json_null = NullableJSONModel.objects.create(value=Value(None, JSONField()))
NullableJSONModel.objects.update(value=Value(None, JSONField()))
json_null.refresh_from_db()
sql_null = NullableJSONModel.objects.create(value=None)
sql_null.refresh_from_db()
# 'null' is not equal to NULL in the database.
self.assertSequenceEqual(
NullableJSONModel.objects.filter(value=Value("null")),
NullableJSONModel.objects.filter(value=Value(None, JSONField())),
[json_null],
)
self.assertSequenceEqual(
Expand Down
Loading

0 comments on commit 0ff4659

Please sign in to comment.