Fixed #35586 -- Added support for set-returning database functions.

Aggregation optimization didn't account for not referenced set-returning annotations on Postgres.

Co-authored-by: Simon Charette <charette.s@gmail.com>
This commit is contained in:
Devin Cox 2024-08-09 13:56:56 -07:00 committed by Sarah Boyce
parent 228128618b
commit e03083917d
6 changed files with 54 additions and 0 deletions

View File

@ -182,6 +182,8 @@ class BaseExpression:
allowed_default = False allowed_default = False
# Can the expression be used during a constraint validation? # Can the expression be used during a constraint validation?
constraint_validation_compatible = True constraint_validation_compatible = True
# Does the expression possibly return more than one row?
set_returning = False
def __init__(self, output_field=None): def __init__(self, output_field=None):
if output_field is not None: if output_field is not None:

View File

@ -491,6 +491,11 @@ class Query(BaseExpression):
) )
or having or having
) )
set_returning_annotations = {
alias
for alias, annotation in self.annotation_select.items()
if getattr(annotation, "set_returning", False)
}
# Decide if we need to use a subquery. # Decide if we need to use a subquery.
# #
# Existing aggregations would cause incorrect results as # Existing aggregations would cause incorrect results as
@ -510,6 +515,7 @@ class Query(BaseExpression):
or qualify or qualify
or self.distinct or self.distinct
or self.combinator or self.combinator
or set_returning_annotations
): ):
from django.db.models.sql.subqueries import AggregateQuery from django.db.models.sql.subqueries import AggregateQuery
@ -551,6 +557,9 @@ class Query(BaseExpression):
if annotation.get_group_by_cols(): if annotation.get_group_by_cols():
annotation_mask.add(annotation_alias) annotation_mask.add(annotation_alias)
inner_query.set_annotation_mask(annotation_mask) inner_query.set_annotation_mask(annotation_mask)
# Annotations that possibly return multiple rows cannot
# be masked as they might have an incidence on the query.
annotation_mask |= set_returning_annotations
# Add aggregates to the outer AggregateQuery. This requires making # Add aggregates to the outer AggregateQuery. This requires making
# sure all columns referenced by the aggregates are selected in the # sure all columns referenced by the aggregates are selected in the

View File

@ -1095,6 +1095,16 @@ calling the appropriate methods on the wrapped expression.
:py:data:`NotImplemented` which forces the expression to be computed on :py:data:`NotImplemented` which forces the expression to be computed on
the database. the database.
.. attribute:: set_returning
.. versionadded:: 5.2
Tells Django that this expression contains a set-returning function,
enforcing subquery evaluation. It's used, for example, to allow some
Postgres set-returning functions (e.g. ``JSONB_PATH_QUERY``,
``UNNEST``, etc.) to skip optimization and be properly evaluated when
annotations spawn rows themselves. Defaults to ``False``.
.. method:: resolve_expression(query=None, allow_joins=True, reuse=None, summarize=False, for_save=False) .. method:: resolve_expression(query=None, allow_joins=True, reuse=None, summarize=False, for_save=False)
Provides the chance to do any preprocessing or validation of Provides the chance to do any preprocessing or validation of

View File

@ -218,6 +218,10 @@ Models
* Added support for validation of model constraints which use a * Added support for validation of model constraints which use a
:class:`~django.db.models.GeneratedField`. :class:`~django.db.models.GeneratedField`.
* The new :attr:`.Expression.set_returning` attribute specifies that the
expression contains a set-returning function, enforcing subquery evaluation.
This is necessary for many Postgres set-returning functions.
Requests and Responses Requests and Responses
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~

View File

@ -58,3 +58,11 @@ class Company(models.Model):
class Ticket(models.Model): class Ticket(models.Model):
active_at = models.DateTimeField() active_at = models.DateTimeField()
duration = models.DurationField() duration = models.DurationField()
class JsonModel(models.Model):
data = models.JSONField(default=dict, blank=True)
id = models.IntegerField(primary_key=True)
class Meta:
required_db_features = {"supports_json_field"}

View File

@ -1,7 +1,9 @@
import datetime import datetime
from decimal import Decimal from decimal import Decimal
from unittest import skipUnless
from django.core.exceptions import FieldDoesNotExist, FieldError from django.core.exceptions import FieldDoesNotExist, FieldError
from django.db import connection
from django.db.models import ( from django.db.models import (
BooleanField, BooleanField,
Case, Case,
@ -15,6 +17,7 @@ from django.db.models import (
FloatField, FloatField,
Func, Func,
IntegerField, IntegerField,
JSONField,
Max, Max,
OuterRef, OuterRef,
Q, Q,
@ -43,6 +46,7 @@ from .models import (
Company, Company,
DepartmentStore, DepartmentStore,
Employee, Employee,
JsonModel,
Publisher, Publisher,
Store, Store,
Ticket, Ticket,
@ -1167,6 +1171,23 @@ class NonAggregateAnnotationTestCase(TestCase):
with self.assertRaisesMessage(ValueError, msg): with self.assertRaisesMessage(ValueError, msg):
Book.objects.annotate(**{crafted_alias: Value(1)}) Book.objects.annotate(**{crafted_alias: Value(1)})
@skipUnless(connection.vendor == "postgresql", "PostgreSQL tests")
@skipUnlessDBFeature("supports_json_field")
def test_set_returning_functions(self):
class JSONBPathQuery(Func):
function = "jsonb_path_query"
output_field = JSONField()
set_returning = True
test_model = JsonModel.objects.create(
data={"key": [{"id": 1, "name": "test1"}, {"id": 2, "name": "test2"}]}, id=1
)
qs = JsonModel.objects.annotate(
table_element=JSONBPathQuery("data", Value("$.key[*]"))
).filter(pk=test_model.pk)
self.assertEqual(qs.count(), len(qs))
class AliasTests(TestCase): class AliasTests(TestCase):
@classmethod @classmethod