Opened 15 hours ago

Last modified 81 minutes ago

#36181 assigned Bug

Composite primary key fields cannot use __in lookup with explicit Subquery

Reported by: Jacob Walls Owned by: Simon Charette
Component: Database layer (models, ORM) Version: 5.2
Severity: Release blocker Keywords:
Cc: Triage Stage: Accepted
Has patch: yes Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

In a recent PR, we surfaced some inconsistencies with the resolution of explicit Subquery() such that some lookups might fail:

  • tests/composite_pk/test_filter.py

    diff --git a/tests/composite_pk/test_filter.py b/tests/composite_pk/test_filter.py
    index 937dd86652..60d43f4a52 100644
    a b class CompositePKFilterTests(TestCase):  
    437437        queryset = User.objects.filter(comments__in=subquery)
    438438        self.assertSequenceEqual(queryset, (self.user_2,))
    439439
     440    def test_explicit_subquery(self):
     441        subquery = Subquery(User.objects.values("pk"))
     442        self.assertEqual(User.objects.filter(pk__in=subquery).count(), 5)
     443        self.assertEqual(Comment.objects.filter(user__in=subquery).count(), 5)
     444
    440445    def test_cannot_cast_pk(self):
    441446        msg = "Cast expression does not support composite primary keys."
    442447        with self.assertRaisesMessage(ValueError, msg):

gives

======================================================================
ERROR: test_explicit_subquery (composite_pk.test_filter.CompositePKFilterTests.test_explicit_subquery)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/.../django/tests/composite_pk/test_filter.py", line 418, in test_explicit_subquery
    self.assertEqual(Comment.objects.filter(user__in=subquery).count(), 5)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/.../django/django/db/models/query.py", line 603, in count
    return self.query.get_count(using=self.db)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/.../django/django/db/models/sql/query.py", line 644, in get_count
    return obj.get_aggregation(using, {"__count": Count("*")})["__count"]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/.../django/django/db/models/sql/query.py", line 626, in get_aggregation
    result = compiler.execute_sql(SINGLE)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/.../django/django/db/models/sql/compiler.py", line 1610, in execute_sql
    sql, params = self.as_sql()
                  ^^^^^^^^^^^^^
  File "/Users/.../django/django/db/models/sql/compiler.py", line 794, in as_sql
    self.compile(self.where) if self.where is not None else ("", [])
    ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/.../django/django/db/models/sql/compiler.py", line 577, in compile
    sql, params = node.as_sql(self, self.connection)
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/.../django/django/db/models/sql/where.py", line 151, in as_sql
    sql, params = compiler.compile(child)
                  ^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/.../django/django/db/models/sql/compiler.py", line 577, in compile
    sql, params = node.as_sql(self, self.connection)
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/.../django/django/db/models/fields/related_lookups.py", line 86, in as_sql
    SubqueryConstraint(
  File "/Users/.../django/django/db/models/sql/where.py", line 358, in __init__
    query_object.clear_ordering(clear_default=True)
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'Subquery' object has no attribute 'clear_ordering'

Change History (2)

comment:1 by Simon Charette, 13 hours ago

Severity: NormalRelease blocker
Triage Stage: UnreviewedAccepted

Well good news, I think the work on #36149 managed to make SubqueryConstraint completely irrelevant while solving this issue.

  • django/db/backends/mysql/compiler.py

    diff --git a/django/db/backends/mysql/compiler.py b/django/db/backends/mysql/compiler.py
    index 2ec6bea2f1..0291b76c70 100644
    a b  
    11from django.core.exceptions import FieldError, FullResultSet
    22from django.db.models.expressions import Col
    3 from django.db.models.sql import compiler
     3from django.db.models.sql.compiler import SQLAggregateCompiler, SQLCompiler
     4from django.db.models.sql.compiler import SQLDeleteCompiler as BaseSQLDeleteCompiler
     5from django.db.models.sql.compiler import SQLInsertCompiler
     6from django.db.models.sql.compiler import SQLUpdateCompiler as BaseSQLUpdateCompiler
    47
     8__all__ = [
     9    "SQLAggregateCompiler",
     10    "SQLCompiler",
     11    "SQLDeleteCompiler",
     12    "SQLInsertCompiler",
     13    "SQLUpdateCompiler",
     14]
    515
    6 class SQLCompiler(compiler.SQLCompiler):
    7     def as_subquery_condition(self, alias, columns, compiler):
    8         qn = compiler.quote_name_unless_alias
    9         qn2 = self.connection.ops.quote_name
    10         sql, params = self.as_sql()
    11         return (
    12             "(%s) IN (%s)"
    13             % (
    14                 ", ".join("%s.%s" % (qn(alias), qn2(column)) for column in columns),
    15                 sql,
    16             ),
    17             params,
    18         )
    19 
    20 
    21 class SQLInsertCompiler(compiler.SQLInsertCompiler, SQLCompiler):
    22     pass
    2316
    24 
    25 class SQLDeleteCompiler(compiler.SQLDeleteCompiler, SQLCompiler):
     17class SQLDeleteCompiler(BaseSQLDeleteCompiler):
    2618    def as_sql(self):
    2719        # Prefer the non-standard DELETE FROM syntax over the SQL generated by
    2820        # the SQLDeleteCompiler's default implementation when multiple tables
    def as_sql(self):  
    5244        return " ".join(result), tuple(params)
    5345
    5446
    55 class SQLUpdateCompiler(compiler.SQLUpdateCompiler, SQLCompiler):
     47class SQLUpdateCompiler(BaseSQLUpdateCompiler):
    5648    def as_sql(self):
    5749        update_query, update_params = super().as_sql()
    5850        # MySQL and MariaDB support UPDATE ... ORDER BY syntax.
    def as_sql(self):  
    7870                # removed in .update() and cannot be resolved.
    7971                pass
    8072        return update_query, update_params
    81 
    82 
    83 class SQLAggregateCompiler(compiler.SQLAggregateCompiler, SQLCompiler):
    84     pass
  • django/db/models/fields/related_lookups.py

    diff --git a/django/db/models/fields/related_lookups.py b/django/db/models/fields/related_lookups.py
    index 38d6308f53..9fc7db7c34 100644
    a b def get_prep_lookup(self):  
    8484
    8585    def as_sql(self, compiler, connection):
    8686        if isinstance(self.lhs, ColPairs):
    87             from django.db.models.sql.where import SubqueryConstraint
    88 
    8987            if self.rhs_is_direct_value():
    9088                values = [get_normalized_value(value, self.lhs) for value in self.rhs]
    9189                lookup = TupleIn(self.lhs, values)
    92                 return compiler.compile(lookup)
    9390            else:
    94                 return compiler.compile(
    95                     SubqueryConstraint(
    96                         self.lhs.alias,
    97                         [target.column for target in self.lhs.targets],
    98                         [source.name for source in self.lhs.sources],
    99                         self.rhs,
    100                     ),
    101                 )
     91                lookup = TupleIn(self.lhs, self.rhs)
     92            return compiler.compile(lookup)
    10293
    10394        return super().as_sql(compiler, connection)
    10495
  • django/db/models/fields/tuple_lookups.py

    diff --git a/django/db/models/fields/tuple_lookups.py b/django/db/models/fields/tuple_lookups.py
    index 98959a6161..e701c7d3de 100644
    a b  
    22
    33from django.core.exceptions import EmptyResultSet
    44from django.db.models import Field
    5 from django.db.models.expressions import ColPairs, Func, ResolvedOuterRef, Value
     5from django.db.models.expressions import (
     6    ColPairs,
     7    Func,
     8    ResolvedOuterRef,
     9    Subquery,
     10    Value,
     11)
    612from django.db.models.lookups import (
    713    Exact,
    814    GreaterThan,
    def check_rhs_elements_length_equals_lhs_length(self):  
    301307            )
    302308
    303309    def check_rhs_is_query(self):
    304         if not isinstance(self.rhs, Query):
     310        if not isinstance(self.rhs, (Query, Subquery)):
    305311            lhs_str = self.get_lhs_str()
    306312            rhs_cls = self.rhs.__class__.__name__
    307313            raise ValueError(
  • django/db/models/sql/compiler.py

    diff --git a/django/db/models/sql/compiler.py b/django/db/models/sql/compiler.py
    index 3bfb3bd631..ef10f00f20 100644
    a b def execute_sql(  
    16611661            return list(result)
    16621662        return result
    16631663
    1664     def as_subquery_condition(self, alias, columns, compiler):
    1665         qn = compiler.quote_name_unless_alias
    1666         qn2 = self.connection.ops.quote_name
    1667         query = self.query.clone()
    1668 
    1669         for index, select_col in enumerate(query.select):
    1670             lhs_sql, lhs_params = self.compile(select_col)
    1671             rhs = "%s.%s" % (qn(alias), qn2(columns[index]))
    1672             query.where.add(RawSQL("%s = %s" % (lhs_sql, rhs), lhs_params), AND)
    1673 
    1674         sql, params = query.as_sql(compiler, self.connection)
    1675         return "EXISTS %s" % sql, params
    1676 
    16771664    def explain_query(self):
    16781665        result = list(self.execute_sql())
    16791666        # Some backends return 1 item tuples with strings, and others return
  • django/db/models/sql/where.py

    diff --git a/django/db/models/sql/where.py b/django/db/models/sql/where.py
    index 0fded5cce3..82f96aa6ec 100644
    a b def __init__(self, sqls, params):  
    343343    def as_sql(self, compiler=None, connection=None):
    344344        sqls = ["(%s)" % sql for sql in self.sqls]
    345345        return " AND ".join(sqls), list(self.params or ())
    346 
    347 
    348 class SubqueryConstraint:
    349     # Even if aggregates or windows would be used in a subquery,
    350     # the outer query isn't interested about those.
    351     contains_aggregate = False
    352     contains_over_clause = False
    353 
    354     def __init__(self, alias, columns, targets, query_object):
    355         self.alias = alias
    356         self.columns = columns
    357         self.targets = targets
    358         query_object.clear_ordering(clear_default=True)
    359         self.query_object = query_object
    360 
    361     def as_sql(self, compiler, connection):
    362         query = self.query_object
    363         query.set_values(self.targets)
    364         query_compiler = query.get_compiler(connection=connection)
    365         return query_compiler.as_subquery_condition(self.alias, self.columns, compiler)
  • tests/composite_pk/test_filter.py

    diff --git a/tests/composite_pk/test_filter.py b/tests/composite_pk/test_filter.py
    index 937dd86652..4b753fb396 100644
    a b def test_cannot_cast_pk(self):  
    442442        with self.assertRaisesMessage(ValueError, msg):
    443443            Comment.objects.filter(text__gt=Cast(F("pk"), TextField())).count()
    444444
     445    def test_explicit_subquery(self):
     446        subquery = Subquery(User.objects.values("pk"))
     447        self.assertEqual(User.objects.filter(pk__in=subquery).count(), 4)
     448        self.assertEqual(Comment.objects.filter(user__in=subquery).count(), 5)
     449
    445450    def test_filter_case_when(self):
    446451        msg = "When expression does not support composite primary keys."
    447452        with self.assertRaisesMessage(ValueError, msg):

Getting rid of SuqueryConstraint and as_subquery_condition was a goal of #373 so that's a great win!

comment:2 by Simon Charette, 81 minutes ago

Has patch: set
Owner: set to Simon Charette
Status: newassigned
Note: See TracTickets for help on using tickets.
Back to Top