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 + `. 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 `. + 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 `. +.. _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()