Skip to content
Open
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
4 changes: 4 additions & 0 deletions django/db/backends/base/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,10 @@ class BaseDatabaseFeatures:

# Does the backend support JSONField?
supports_json_field = True
# Does the backend implement support for JSON_ARRAY(... ABSENT ON NULL)?
supports_json_absent_on_null = True
# Does the backend support concatenating JSON arrays?
supports_json_array_concat = True
# Can the backend introspect a JSONField?
can_introspect_json_field = True
# Does the backend support primitives in JSONField?
Expand Down
2 changes: 2 additions & 0 deletions django/db/backends/sqlite3/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_aggregate_distinct_multiple_argument = False
supports_any_value = True
order_by_nulls_first = True
supports_json_absent_on_null = False
supports_json_array_concat = False
supports_json_field_contains = False
supports_update_conflicts = True
supports_update_conflicts_with_target = True
Expand Down
102 changes: 95 additions & 7 deletions django/db/models/functions/json.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,118 @@
from django.db import NotSupportedError
from django.db.models.expressions import Func, Value
from django.db.models.expressions import Case, Func, Value, When
from django.db.models.fields import TextField
from django.db.models.fields.json import JSONField
from django.db.models.functions import Cast
from django.db.models.lookups import IsNull


class _JSONArrayConcat(Func):
# Concatenate multiple JSON arrays into a single JSON array.
# If any value is NULL, the entire array is NULL.
# Duplicates are preserved.
# This function cannot take objects because the behavior is backend dependent.
# For example, on MySQL the merge is recursive, but on PostgreSQL
# it is not.

function = None
output_field = JSONField()

def __init__(self, *expressions, **extra):
if len(expressions) < 2:
raise ValueError("_JSONArrayConcat must take at least two expressions")
super().__init__(*expressions, **extra)

def as_sql(self, compiler, connection, **extra_context):
if not connection.features.supports_json_array_concat:
raise NotSupportedError(
"Concatenating JSON arrays is not supported on this database backend."
)
return super().as_sql(compiler, connection, **extra_context)

def pipes_concat_sql(self, compiler, connection, **extra_context):
return super().as_sql(
compiler,
connection,
template="(%(expressions)s)",
arg_joiner=" || ",
**extra_context,
)

def as_mysql(self, compiler, connection, **extra_context):
return super().as_sql(
compiler,
connection,
function="JSON_MERGE_PRESERVE",
**extra_context,
)

def as_oracle(self, compiler, connection, **extra_context):
return self.pipes_concat_sql(compiler, connection, **extra_context)

def as_postgresql(self, compiler, connection, **extra_context):
return self.pipes_concat_sql(compiler, connection, **extra_context)


class JSONArray(Func):
function = "JSON_ARRAY"
output_field = JSONField()

def __init__(self, *expressions, absent_on_null=False):
self.absent_on_null = absent_on_null
super().__init__(*expressions)

def _absent_on_null_workaround(self, compiler):
# On backends that do not support ABSENT ON NULL, we can implement the behavior
# so long as the backend has a way to concatenate JSON arrays.
unit_arrays = [
Case(
When(IsNull(expression, True), then=JSONArray()),
default=JSONArray(expression),
)
for expression in self.get_source_expressions()
]

if len(unit_arrays) == 0:
expression = JSONArray()
elif len(unit_arrays) == 1:
expression = unit_arrays[0]
else:
expression = _JSONArrayConcat(*unit_arrays)

return compiler.compile(expression)

def as_sql(self, compiler, connection, **extra_context):
if not connection.features.supports_json_field:
raise NotSupportedError(
"JSONFields are not supported on this database backend."
)
if self.absent_on_null and not connection.features.supports_json_absent_on_null:
raise NotSupportedError(
"ABSENT ON NULL is not supported by this database backend."
)
return super().as_sql(compiler, connection, **extra_context)

def as_mysql(self, compiler, connection, **extra_context):
if self.absent_on_null:
return self._absent_on_null_workaround(compiler)

return super().as_sql(compiler, connection, **extra_context)

def as_native(self, compiler, connection, *, returning, **extra_context):
# PostgreSQL 16+ and Oracle remove SQL NULL values from the array by
# default. Adds the NULL ON NULL clause to keep NULL values in the
# array, mapping them to JSON null values, which matches the behavior
# of SQLite.
null_on_null = "NULL ON NULL" if len(self.get_source_expressions()) > 0 else ""
# Providing the ON NULL clause when no source expressions are provided is a
# syntax error on some backends.
if len(self.get_source_expressions()) == 0:
on_null_clause = ""
elif self.absent_on_null:
on_null_clause = "ABSENT ON NULL"
else:
on_null_clause = "NULL ON NULL"

return self.as_sql(
compiler,
connection,
template=(
f"%(function)s(%(expressions)s {null_on_null} RETURNING {returning})"
f"%(function)s(%(expressions)s {on_null_clause} RETURNING {returning})"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is a VERY minor quibble, but if you don't have any source expressions, the generate SQL will end up having a triple space in it.

If the spaces were moved to lines 33 and 35, and then removed from the template here, it would resolve that, at the expense of making it slightly awkward right here.

Is that even something worth discussing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if I were hand-writing the SQL, I would write it like JSON_OBJECT(RETURNING JSONB), but the ORM will emit JSON_OBJECT( RETURNING JSONB)

),
**extra_context,
)
Expand All @@ -54,6 +139,9 @@ def as_postgresql(self, compiler, connection, **extra_context):
compiler, connection, returning="JSONB", **extra_context
)

if self.absent_on_null:
return casted_obj._absent_on_null_workaround(compiler)

return casted_obj.as_sql(
compiler,
connection,
Expand Down
28 changes: 27 additions & 1 deletion docs/ref/models/database-functions.txt
Original file line number Diff line number Diff line change
Expand Up @@ -846,7 +846,7 @@ JSON Functions

.. versionadded:: 5.2

.. class:: JSONArray(*expressions)
.. class:: JSONArray(*expressions, absent_on_null=False)

Accepts a list of field names or expressions and returns a JSON array
containing those values.
Expand All @@ -863,11 +863,37 @@ Usage example:
... Lower("name"),
... "alias",
... F("age") * 2,
... "goes_by",
... )
... ).get()
>>> author.json_array
['margaret smith', 'msmith', 50, null]

Set the ``absent_on_null`` parameter to ``True`` to omit null values.

.. versionadded:: 6.0

.. code-block:: pycon

>>> from django.db.models import F
>>> from django.db.models.functions import JSONArray, Lower
>>> Author.objects.create(name="Margaret Smith", alias="msmith", age=25)
>>> author = Author.objects.annotate(
... json_array=JSONArray(
... Lower("name"),
... "alias",
... F("age") * 2,
... "goes_by",
... absent_on_null=True,
... )
... ).get()
>>> author.json_array
['margaret smith', 'msmith', 50]

.. admonition:: SQLite

SQLite doesn't support ``True`` for the ``absent_on_null`` parameter.

``JSONObject``
--------------

Expand Down
3 changes: 3 additions & 0 deletions docs/releases/6.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,9 @@ Pagination
:class:`~django.core.paginator.AsyncPage` provide async implementations of
:class:`~django.core.paginator.Paginator` and
:class:`~django.core.paginator.Page` respectively.
* The :class:`~django.db.models.functions.JSONArray` database function now
accepts an ``absent_on_null`` parameter to control whether ``null`` values
are omitted.

Requests and Responses
~~~~~~~~~~~~~~~~~~~~~~
Expand Down
40 changes: 40 additions & 0 deletions tests/db_functions/json/test_json_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

@skipUnlessDBFeature("supports_json_field")
class JSONArrayTests(TestCase):

@classmethod
def setUpTestData(cls):
Author.objects.create(name="Ivan Ivanov", alias="iivanov")
Expand Down Expand Up @@ -107,6 +108,45 @@ def test_order_by_nested_key(self):
)
self.assertQuerySetEqual(qs, Author.objects.order_by("-alias"))

def test_null_on_null(self):
obj = Author.objects.annotate(
arr=JSONArray(F("goes_by"), absent_on_null=False)
).first()

self.assertEqual(obj.arr, [None])

@skipIfDBFeature("supports_json_absent_on_null")
def test_absent_on_null_not_supported(self):
msg = "ABSENT ON NULL is not supported by this database backend."
with self.assertRaisesMessage(NotSupportedError, msg):
Author.objects.annotate(
arr=JSONArray(F("goes_by"), absent_on_null=True)
).first()

@skipUnlessDBFeature("supports_json_absent_on_null")
def test_absent_on_null(self):
obj = Author.objects.annotate(
arr=JSONArray(F("name"), F("goes_by"), absent_on_null=True)
).first()
self.assertEqual(obj.arr, ["Ivan Ivanov"])

@skipUnlessDBFeature("supports_json_absent_on_null")
def test_empty_absent_on_null(self):
obj = Author.objects.annotate(json_array=JSONArray(absent_on_null=True)).first()
self.assertEqual(obj.json_array, [])

@skipUnlessDBFeature("supports_json_absent_on_null")
def test_single_absent_on_null(self):
obj_null = Author.objects.annotate(
json_array=JSONArray(F("goes_by"), absent_on_null=True)
).first()
self.assertEqual(obj_null.json_array, [])

obj_non_null = Author.objects.annotate(
json_array=JSONArray(F("name"), absent_on_null=True)
).first()
self.assertEqual(obj_non_null.json_array, ["Ivan Ivanov"])


@skipIfDBFeature("supports_json_field")
class JSONArrayNotSupportedTests(TestCase):
Expand Down
63 changes: 63 additions & 0 deletions tests/db_functions/json/test_json_concat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from django.db import NotSupportedError
from django.db.models import F, Value
from django.db.models.functions import JSONArray
from django.db.models.functions.json import _JSONArrayConcat as JSONConcat
from django.test import TestCase
from django.test.testcases import skipIfDBFeature, skipUnlessDBFeature

from ..models import Author


@skipUnlessDBFeature("supports_json_array_concat")
class JSONArrayConcatTests(TestCase):
@classmethod
def setUpTestData(cls):
Author.objects.bulk_create(
[
Author(name="Ivan Ivanov", alias="iivanov"),
]
)

def test_invalid(self):
msg = "_JSONArrayConcat must take at least two expressions"
with self.assertRaisesMessage(ValueError, msg):
Author.objects.annotate(json=JSONConcat()).first()
with self.assertRaisesMessage(ValueError, msg):
Author.objects.annotate(json=JSONConcat(JSONArray(F("name")))).first()

def test_simple_array(self):
obj = Author.objects.annotate(
arr=JSONConcat(
JSONArray("name"),
JSONArray("alias"),
)
).first()
self.assertEqual(obj.arr, ["Ivan Ivanov", "iivanov"])

def test_array_and_null(self):
obj = Author.objects.annotate(
json=JSONConcat(JSONArray("name"), Value(None))
).first()
self.assertEqual(obj.json, None)

def test_duplicates_preserved(self):
obj = Author.objects.annotate(
arr=JSONConcat(
JSONArray("name"),
JSONArray("name"),
)
).first()
self.assertEqual(obj.arr, ["Ivan Ivanov", "Ivan Ivanov"])


@skipIfDBFeature("has_json_object_function")
class JSONArrayConcatNotSupportedTests(TestCase):
def test_not_supported(self):
msg = "Concatenating JSON arrays is not supported on this database backend."
with self.assertRaisesMessage(NotSupportedError, msg):
Author.objects.annotate(
arr=JSONConcat(
JSONArray("name"),
JSONArray("alias"),
)
).first()