summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJacob Walls <jacobtylerwalls@gmail.com>2025-01-05 10:09:46 -0500
committerSarah Boyce <42296566+sarahboyce@users.noreply.github.com>2025-01-07 11:42:06 +0100
commit470e5545e56e8510f9b9d39a96d7094fedd9c45a (patch)
tree371d33bcc1bcbbf205137aa6627b17fa4f751e6f
parent6eec703667505d87d1354323548943c8d546c5a2 (diff)
Refs #36042 -- Raised ValueError when providing composite expressions to aggregates.
-rw-r--r--django/db/models/aggregates.py1
-rw-r--r--django/db/models/expressions.py9
-rw-r--r--django/db/models/fields/tuple_lookups.py3
-rw-r--r--docs/ref/models/expressions.txt8
-rw-r--r--docs/releases/5.2.txt4
-rw-r--r--docs/topics/composite-primary-key.txt10
-rw-r--r--tests/composite_pk/test_aggregate.py7
-rw-r--r--tests/composite_pk/test_filter.py2
8 files changed, 39 insertions, 5 deletions
diff --git a/django/db/models/aggregates.py b/django/db/models/aggregates.py
index 8491506aad..911b60a86d 100644
--- a/django/db/models/aggregates.py
+++ b/django/db/models/aggregates.py
@@ -166,6 +166,7 @@ class Count(Aggregate):
output_field = IntegerField()
allow_distinct = True
empty_result_set_value = 0
+ allows_composite_expressions = True
def __init__(self, expression, filter=None, **extra):
if expression == "*":
diff --git a/django/db/models/expressions.py b/django/db/models/expressions.py
index 88c5596c17..746fe04143 100644
--- a/django/db/models/expressions.py
+++ b/django/db/models/expressions.py
@@ -184,6 +184,8 @@ class BaseExpression:
constraint_validation_compatible = True
# Does the expression possibly return more than one row?
set_returning = False
+ # Does the expression allow composite expressions?
+ allows_composite_expressions = False
def __init__(self, output_field=None):
if output_field is not None:
@@ -1077,6 +1079,12 @@ class Func(SQLiteNumericMixin, Expression):
c.source_expressions[pos] = arg.resolve_expression(
query, allow_joins, reuse, summarize, for_save
)
+ if not self.allows_composite_expressions and any(
+ isinstance(expr, ColPairs) for expr in c.get_source_expressions()
+ ):
+ raise ValueError(
+ f"{self.__class__.__name__} does not support composite primary keys."
+ )
return c
def as_sql(
@@ -1827,6 +1835,7 @@ class OrderBy(Expression):
template = "%(expression)s %(ordering)s"
conditional = False
constraint_validation_compatible = False
+ allows_composite_expressions = True
def __init__(self, expression, descending=False, nulls_first=None, nulls_last=None):
if nulls_first and nulls_last:
diff --git a/django/db/models/fields/tuple_lookups.py b/django/db/models/fields/tuple_lookups.py
index e515e971b4..51432a666b 100644
--- a/django/db/models/fields/tuple_lookups.py
+++ b/django/db/models/fields/tuple_lookups.py
@@ -17,6 +17,7 @@ from django.db.models.sql.where import AND, OR, WhereNode
class Tuple(Func):
+ allows_composite_expressions = True
function = ""
output_field = Field()
@@ -28,6 +29,8 @@ class Tuple(Func):
class TupleLookupMixin:
+ allows_composite_expressions = True
+
def get_prep_lookup(self):
self.check_rhs_is_tuple_or_list()
self.check_rhs_length_equals_lhs_length()
diff --git a/docs/ref/models/expressions.txt b/docs/ref/models/expressions.txt
index f273b00a9e..6faec969c3 100644
--- a/docs/ref/models/expressions.txt
+++ b/docs/ref/models/expressions.txt
@@ -1105,6 +1105,14 @@ calling the appropriate methods on the wrapped expression.
``UNNEST``, etc.) to skip optimization and be properly evaluated when
annotations spawn rows themselves. Defaults to ``False``.
+ .. attribute:: allows_composite_expressions
+
+ .. versionadded:: 5.2
+
+ Tells Django that this expression allows composite expressions, for
+ example, to support :ref:`composite primary keys
+ <cpk-and-database-functions>`. Defaults to ``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
diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt
index 35c602c4e2..f1d68d234f 100644
--- a/docs/releases/5.2.txt
+++ b/docs/releases/5.2.txt
@@ -330,6 +330,10 @@ Models
accepts a list of field names or expressions and returns a JSON array
containing those values.
+* The new :attr:`.Expression.allows_composite_expressions` attribute specifies
+ that the expression allows composite expressions, for example, to support
+ :ref:`composite primary keys <cpk-and-database-functions>`.
+
Requests and Responses
~~~~~~~~~~~~~~~~~~~~~~
diff --git a/docs/topics/composite-primary-key.txt b/docs/topics/composite-primary-key.txt
index 9e5234ca9f..f252f318c1 100644
--- a/docs/topics/composite-primary-key.txt
+++ b/docs/topics/composite-primary-key.txt
@@ -131,6 +131,8 @@ database.
``ForeignObject`` is an internal API. This means it is not covered by our
:ref:`deprecation policy <internal-release-deprecation-policy>`.
+.. _cpk-and-database-functions:
+
Composite primary keys and database functions
=============================================
@@ -141,13 +143,15 @@ Many database functions only accept a single expression.
MAX("order_id") -- OK
MAX("product_id", "order_id") -- ERROR
-As a consequence, they cannot be used with composite primary key references as
-they are composed of multiple column expressions.
+In these cases, providing a composite primary key reference raises a
+``ValueError``, since it is composed of multiple column expressions. An
+exception is made for ``Count``.
.. code-block:: python
Max("order_id") # OK
- Max("pk") # ERROR
+ Max("pk") # ValueError
+ Count("pk") # OK
Composite primary keys in forms
===============================
diff --git a/tests/composite_pk/test_aggregate.py b/tests/composite_pk/test_aggregate.py
index 7b40bffe9a..e8751df0a3 100644
--- a/tests/composite_pk/test_aggregate.py
+++ b/tests/composite_pk/test_aggregate.py
@@ -1,4 +1,4 @@
-from django.db.models import Count, Q
+from django.db.models import Count, Max, Q
from django.test import TestCase
from .models import Comment, Tenant, User
@@ -136,3 +136,8 @@ class CompositePKAggregateTests(TestCase):
),
(self.user_3, self.user_1, self.user_2),
)
+
+ def test_max_pk(self):
+ msg = "Max does not support composite primary keys."
+ with self.assertRaisesMessage(ValueError, msg):
+ Comment.objects.aggregate(Max("pk"))
diff --git a/tests/composite_pk/test_filter.py b/tests/composite_pk/test_filter.py
index a99a1602a0..06cd6781df 100644
--- a/tests/composite_pk/test_filter.py
+++ b/tests/composite_pk/test_filter.py
@@ -428,6 +428,6 @@ class CompositePKFilterTests(TestCase):
self.assertSequenceEqual(queryset, (self.user_2,))
def test_cannot_cast_pk(self):
- msg = "Casting CompositePrimaryKey is not supported."
+ msg = "Cast does not support composite primary keys."
with self.assertRaisesMessage(ValueError, msg):
Comment.objects.filter(text__gt=Cast(F("pk"), TextField())).count()