Added support for NULL ON NULL and ABSENT ON NULL on JSONArrayAgg and added necessary tests.

This commit is contained in:
lufafajoshua 2025-10-21 15:07:04 +03:00
parent 66e4bb9454
commit 9a95c7154a
5 changed files with 83 additions and 11 deletions

View file

@ -339,6 +339,8 @@ class BaseDatabaseFeatures:
# Does the backend support JSONField? # Does the backend support JSONField?
supports_json_field = True supports_json_field = True
# Does the backend implement support for ABSENT ON NULL clause?
supports_json_absent_on_null = True
# Can the backend introspect a JSONField? # Can the backend introspect a JSONField?
can_introspect_json_field = True can_introspect_json_field = True
# Does the backend support primitives in JSONField? # Does the backend support primitives in JSONField?

View file

@ -60,6 +60,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_virtual_generated_columns = True supports_virtual_generated_columns = True
supports_json_negative_indexing = False supports_json_negative_indexing = False
supports_json_absent_on_null = False
@cached_property @cached_property
def minimum_database_version(self): def minimum_database_version(self):

View file

@ -36,6 +36,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_aggregate_distinct_multiple_argument = False supports_aggregate_distinct_multiple_argument = False
supports_any_value = True supports_any_value = True
order_by_nulls_first = True order_by_nulls_first = True
supports_json_absent_on_null = False
supports_json_field_contains = False supports_json_field_contains = False
supports_update_conflicts = True supports_update_conflicts = True
supports_update_conflicts_with_target = True supports_update_conflicts_with_target = True

View file

@ -20,6 +20,7 @@ from django.db.models.functions.mixins import (
FixDurationInputMixin, FixDurationInputMixin,
NumericOutputFieldMixin, NumericOutputFieldMixin,
) )
from django.db.models.lookups import IsNull
__all__ = [ __all__ = [
"Aggregate", "Aggregate",
@ -407,11 +408,20 @@ class JSONArrayAgg(Aggregate):
allow_order_by = True allow_order_by = True
arity = 1 arity = 1
def __init__(self, *expressions, absent_on_null=False, **extra):
self.absent_on_null = absent_on_null
super().__init__(*expressions, **extra)
def as_sql(self, compiler, connection, **extra_context): def as_sql(self, compiler, connection, **extra_context):
if self.filter and not connection.features.supports_aggregate_filter_clause: if self.filter and not connection.features.supports_aggregate_filter_clause:
raise NotSupportedError( raise NotSupportedError(
"JSONArrayAgg(filter) is not supported on this database backend." "JSONArrayAgg(filter) is not supported on this database backend."
) )
if self.absent_on_null and not connection.features.supports_json_absent_on_null:
raise NotSupportedError(
"JSONArrayAgg(absent_on_null) is not supported on this database "
"backend."
)
return super().as_sql(compiler, connection, **extra_context) return super().as_sql(compiler, connection, **extra_context)
def as_mysql(self, compiler, connection, **extra_context): def as_mysql(self, compiler, connection, **extra_context):
@ -444,21 +454,60 @@ class JSONArrayAgg(Aggregate):
sql = f"(CASE WHEN {count_sql} > 0 THEN {sql}{default_sql} END)" sql = f"(CASE WHEN {count_sql} > 0 THEN {sql}{default_sql} END)"
return sql, count_params + params + default_params return sql, count_params + params + default_params
def as_native(self, compiler, connection, *, returning=None, **extra_context):
# Oracle and PostgreSQL 16+ default to removing SQL null values from
# the returned array. This adds the NULL ON NULL clause to preserve
# the null values in the array as default behaviour Similar to that
# of SQLite, also removes the null values from the array when
# specified via ABSENT ON NULL.
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"
if returning:
extra_context.setdefault(
"template",
"%(function)s(%(distinct)s%(expressions)s%(order_by)s "
f"{on_null_clause} RETURNING {returning}) %(filter)s",
)
else:
extra_context.setdefault(
"template",
"%(function)s(%(distinct)s%(expressions)s%(order_by)s "
f"{on_null_clause}) %(filter)s",
)
return self.as_sql(compiler, connection, **extra_context)
def as_postgresql(self, compiler, connection, **extra_context): def as_postgresql(self, compiler, connection, **extra_context):
if not connection.features.is_postgresql_16: if not connection.features.is_postgresql_16:
sql, params = super().as_sql( sql, params = self.as_sql(
compiler, compiler,
connection, connection,
function="ARRAY_AGG", function="ARRAY_AGG",
**extra_context, **extra_context,
) )
return f"TO_JSONB({sql})", params # Use a filter to cleanly remove null values from the array to
extra_context.setdefault( # match the behaviour of ABSENT ON NULL on Oracle and
"template", # PostgreSQL 16+.
"%(function)s(%(distinct)s%(expressions)s%(order_by)s RETURNING JSONB)\ if self.absent_on_null:
%(filter)s", expression = self.get_source_expressions()[0]
) if self.filter:
return self.as_sql(compiler, connection, **extra_context) not_null_condition = IsNull(expression, False)
copy = self.copy()
copy.filter.source_expressions[0].children += [not_null_condition]
sql, params = copy.as_sql(
compiler, connection, function="ARRAY_AGG", **extra_context
)
return f"TO_JSONB({sql})", params
else:
expr, _ = compiler.compile(expression)
filter_sql = f"FILTER (WHERE {expr} IS NOT NULL)"
return f"TO_JSONB({sql} {filter_sql})", params
else:
return f"TO_JSONB({sql})", params
return self.as_native(compiler, connection, returning="JSONB", **extra_context)
def as_oracle(self, compiler, connection, **extra_context): def as_oracle(self, compiler, connection, **extra_context):
# Oracle turns DATE columns into ISO 8601 timestamp including T00:00:00 # Oracle turns DATE columns into ISO 8601 timestamp including T00:00:00
@ -474,5 +523,5 @@ class JSONArrayAgg(Aggregate):
*source_expressions[1:], *source_expressions[1:],
] ]
) )
return clone.as_sql(compiler, connection, **extra_context) return clone.as_native(compiler, connection, **extra_context)
return self.as_sql(compiler, connection, **extra_context) return self.as_native(compiler, connection, **extra_context)

View file

@ -2737,7 +2737,7 @@ class AggregateAnnotationPruningTests(TestCase):
class JSONArrayAggTests(TestCase): class JSONArrayAggTests(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.a1 = Author.objects.create(name="Adrian Holovaty", age=34) cls.a1 = Author.objects.create(name="Adrian Holovaty", age=34, rating=1.5)
cls.a2 = Author.objects.create(name="Jacob Kaplan-Moss", age=45) cls.a2 = Author.objects.create(name="Jacob Kaplan-Moss", age=45)
cls.a3 = Author.objects.create(name="Brad Dayley", age=40) cls.a3 = Author.objects.create(name="Brad Dayley", age=40)
cls.p1 = Publisher.objects.create(num_awards=3) cls.p1 = Publisher.objects.create(num_awards=3)
@ -2799,6 +2799,17 @@ class JSONArrayAggTests(TestCase):
vals = Author.objects.aggregate(jsonarrayagg=JSONArrayAgg("book__pages")) vals = Author.objects.aggregate(jsonarrayagg=JSONArrayAgg("book__pages"))
self.assertEqual(vals, {"jsonarrayagg": [447, 528, 300]}) self.assertEqual(vals, {"jsonarrayagg": [447, 528, 300]})
def test_null_on_null(self):
vals = Author.objects.aggregate(jsonarrayagg=JSONArrayAgg("rating"))
self.assertEqual(vals, {"jsonarrayagg": [1.5, None, None]})
@skipUnlessDBFeature("supports_json_absent_on_null")
def test_absent_on_null(self):
vals = Author.objects.aggregate(
jsonarrayagg=JSONArrayAgg("rating", absent_on_null=True)
)
self.assertEqual(vals, {"jsonarrayagg": [1.5]})
@skipUnlessDBFeature("supports_aggregate_filter_clause") @skipUnlessDBFeature("supports_aggregate_filter_clause")
def test_filter(self): def test_filter(self):
vals = Book.objects.aggregate( vals = Book.objects.aggregate(
@ -2842,6 +2853,14 @@ class JSONArrayAggTests(TestCase):
with self.assertRaisesMessage(NotSupportedError, msg): with self.assertRaisesMessage(NotSupportedError, msg):
Author.objects.aggregate(arrayagg=JSONArrayAgg("age", order_by="-name")) Author.objects.aggregate(arrayagg=JSONArrayAgg("age", order_by="-name"))
@skipIfDBFeature("supports_json_absent_on_null")
def test_absent_on_null_not_supported(self):
msg = "JSONArrayAgg(absent_on_null) is not supported on this database backend."
with self.assertRaisesMessage(NotSupportedError, msg):
Author.objects.aggregate(
arrayagg=JSONArrayAgg("rating", absent_on_null=True)
)
def test_distinct_true(self): def test_distinct_true(self):
msg = "JSONArrayAgg does not allow distinct." msg = "JSONArrayAgg does not allow distinct."
with self.assertRaisesMessage(TypeError, msg): with self.assertRaisesMessage(TypeError, msg):