【问题标题】:Django Left Outer JoinDjango 左外连接
【发布时间】:2016-10-29 20:06:31
【问题描述】:

我有一个网站,用户可以在其中查看电影列表并为它们创建评论。

用户应该能够看到所有电影的列表。此外,如果他们看过这部电影,他们应该能够看到他们给它的分数。如果没有,则电影只显示,不带乐谱。

他们根本不关心其他用户提供的分数。

考虑以下models.py

from django.contrib.auth.models import User
from django.db import models


class Topic(models.Model):
    name = models.TextField()

    def __str__(self):
        return self.name


class Record(models.Model):
    user = models.ForeignKey(User)
    topic = models.ForeignKey(Topic)
    value = models.TextField()

    class Meta:
        unique_together = ("user", "topic")

我真正想要的是这个

select * from bar_topic
left join (select topic_id as tid, value from bar_record where user_id = 1)
on tid = bar_topic.id

考虑以下test.py 的上下文:

from django.test import TestCase

from bar.models import *


from django.db.models import Q

class TestSuite(TestCase):

    def setUp(self):
        t1 = Topic.objects.create(name="A")
        t2 = Topic.objects.create(name="B")
        t3 = Topic.objects.create(name="C")
        # 2 for Johnny
        johnny = User.objects.create(username="Johnny")
        johnny.record_set.create(topic=t1, value=1)
        johnny.record_set.create(topic=t3, value=3)
        # 3 for Mary
        mary = User.objects.create(username="Mary")
        mary.record_set.create(topic=t1, value=4)
        mary.record_set.create(topic=t2, value=5)
        mary.record_set.create(topic=t3, value=6)

    def test_raw(self):
        print('\nraw\n---')
        with self.assertNumQueries(1):
            topics = Topic.objects.raw('''
                select * from bar_topic
                left join (select topic_id as tid, value from bar_record where user_id = 1)
                on tid = bar_topic.id
                ''')
            for topic in topics:
                print(topic, topic.value)

    def test_orm(self):
        print('\norm\n---')
        with self.assertNumQueries(1):
            topics = Topic.objects.filter(Q(record__user_id=1)).values_list('name', 'record__value')
            for topic in topics:
                print(*topic)

两个测试都应该打印完全相同的输出,但是,只有原始版本会输出正确的结果表:

原始的
---
1
B 无
C 3

orm 会返回这个

orm
---
1
C 3

任何尝试加入其余主题,即没有用户“johnny”评论的主题,都会导致以下结果:

orm
---
A 1
A 4
B 5
C 3
C 6

如何使用 Django ORM 完成原始查询的简单行为?

编辑:这种工作,但似乎很差:

topics = Topic.objects.filter(record__user_id=1).values_list('name', 'record__value')
noned = Topic.objects.exclude(record__user_id=1).values_list('name')
对于链中的主题(主题,无):
    ...

编辑:这效果好一点,但仍然很糟糕:

 主题 = Topic.objects.filter(record__user_id=1).annotate(value=F('record__value'))
    主题 |= Topic.objects.exclude(pk__in=topics)
orm
---
1
乙 5
C 3

【问题讨论】:

标签: python django django-models orm


【解决方案1】:

首先,没有办法(atm Django 1.9.7)用 Django 的 ORM 来表示您发布的原始查询,完全正确 随心所欲;但是,您可以通过以下方式获得相同的预期结果:

>>> Topic.objects.annotate(
        f=Case(
            When(
                record__user=johnny, 
                then=F('record__value')
            ), 
            output_field=IntegerField()
        )
    ).order_by(
        'id', 'name', 'f'
    ).distinct(
        'id', 'name'
    ).values_list(
        'name', 'f'
    )
>>> [(u'A', 1), (u'B', None), (u'C', 3)]

>>> Topic.objects.annotate(f=Case(When(record__user=may, then=F('record__value')), output_field=IntegerField())).order_by('id', 'name', 'f').distinct('id', 'name').values_list('name', 'f')
>>> [(u'A', 4), (u'B', 5), (u'C', 6)]

这里是为第一个查询生成的 SQL:

>>> print Topic.objects.annotate(f=Case(When(record__user=johnny, then=F('record__value')), output_field=IntegerField())).order_by('id', 'name', 'f').distinct('id', 'name').values_list('name', 'f').query

>>> SELECT DISTINCT ON ("payments_topic"."id", "payments_topic"."name") "payments_topic"."name", CASE WHEN "payments_record"."user_id" = 1 THEN "payments_record"."value" ELSE NULL END AS "f" FROM "payments_topic" LEFT OUTER JOIN "payments_record" ON ("payments_topic"."id" = "payments_record"."topic_id") ORDER BY "payments_topic"."id" ASC, "payments_topic"."name" ASC, "f" ASC

##一些笔记

  • 毫不犹豫地使用原始查询,特别是当性能是重要的事情时。此外,有时这是必须的,因为使用 Django 的 ORM 无法获得相同的结果;在其他情况下你可以,但偶尔拥有干净易懂的代码比这段代码的性能更重要。
  • 这个答案中使用了带有位置参数的distinct,它仅适用于 PostgreSQL,atm。在文档中,您可以看到更多关于 conditional expressions 的信息。

【讨论】:

  • 这真的很好,但我会坚持一会儿,以防有其他好的答案。小问题是: distinct 在 SQLite 上不起作用,非常需要 distinct 步骤。
  • @RodericDay 但问题被标记为 mysql
  • 我的错!我只是选择了自动推荐的标签,没有经过足够的思考。不过,“distinct”适用于 postgres。
  • +1。唯一的反对意见是应该在 distinct 子句 distinct('id', 'name') 中添加一个唯一字段,否则如果意外可能出现第二个同名对象,则可能会丢失一行。
  • 我已经用一些建议更新了答案。谢谢@hynekcer
【解决方案2】:

我真正想要的是这个

select * from bar_topic
left join (select topic_id as tid, value from bar_record where user_id = 1)
on tid = bar_topic.id

...或者,也许这个等价物避免了子查询...

select * from bar_topic
left join bar_record
on bar_record.topic_id = bar_topic.id and bar_record.user_id = 1

我想知道如何有效地做到这一点,或者,如果不可能,解释为什么不可能......

除非您使用原始查询,否则使用 Django 的 ORM 是不可能的,原因如下。

QuerySet 对象 (django.db.models.query.QuerySet) 有一个 query 属性 (django.db.models.sql.query.Query),它表示将要执行的实际查询。这些Query 对象有一个__str__ 方法很有帮助,因此您可以打印出来看看它是什么。

让我们从一个简单的QuerySet开始...

>>> from bar.models import *
>>> qs = Topic.objects.filter(record__user_id=1)
>>> print qs.query
SELECT "bar_topic"."id", "bar_topic"."name" FROM "bar_topic" INNER JOIN "bar_record" ON ("bar_topic"."id" = "bar_record"."topic_id") WHERE "bar_record"."user_id" = 1

...由于INNER JOIN,这显然行不通。

深入了解Query 对象内部,有一个alias_map 属性确定将执行哪些表连接...

>>> from pprint import pprint
>>> pprint(qs.query.alias_map)
{u'bar_record': JoinInfo(table_name=u'bar_record', rhs_alias=u'bar_record', join_type='INNER JOIN', lhs_alias=u'bar_topic', lhs_join_col=u'id', rhs_join_col='topic_id', nullable=True),
 u'bar_topic': JoinInfo(table_name=u'bar_topic', rhs_alias=u'bar_topic', join_type=None, lhs_alias=None, lhs_join_col=None, rhs_join_col=None, nullable=False),
 u'auth_user': JoinInfo(table_name=u'auth_user', rhs_alias=u'auth_user', join_type='INNER JOIN', lhs_alias=u'bar_record', lhs_join_col='user_id', rhs_join_col=u'id', nullable=False)}

请注意,Django 仅支持两种可能的join_types、INNER JOINLEFT OUTER JOIN

现在,我们可以使用Query 对象的promote_joins 方法在bar_record 表上使用LEFT OUTER JOIN...

>>> qs.query.promote_joins(['bar_record'])
>>> pprint(qs.query.alias_map)
{u'bar_record': JoinInfo(table_name=u'bar_record', rhs_alias=u'bar_record', join_type='LEFT OUTER JOIN', lhs_alias=u'bar_topic', lhs_join_col=u'id', rhs_join_col='topic_id', nullable=True),
 u'bar_topic': JoinInfo(table_name=u'bar_topic', rhs_alias=u'bar_topic', join_type=None, lhs_alias=None, lhs_join_col=None, rhs_join_col=None, nullable=False),
 u'auth_user': JoinInfo(table_name=u'auth_user', rhs_alias=u'auth_user', join_type='LEFT OUTER JOIN', lhs_alias=u'bar_record', lhs_join_col='user_id', rhs_join_col=u'id', nullable=False)}

...这会将查询更改为...

>>> print qs.query
SELECT "bar_topic"."id", "bar_topic"."name" FROM "bar_topic" LEFT OUTER JOIN "bar_record" ON ("bar_topic"."id" = "bar_record"."topic_id") WHERE "bar_record"."user_id" = 1

...但是,这仍然没有用,因为连接将始终匹配一行,即使它不属于正确的用户,WHERE 子句会将其过滤掉。

使用values_list() 会自动影响join_type...

>>> qs = Topic.objects.filter(record__user_id=1).values_list('name', 'record__value')
>>> print qs.query
SELECT "bar_topic"."name", "bar_record"."value" FROM "bar_topic" LEFT OUTER JOIN "bar_record" ON ("bar_topic"."id" = "bar_record"."topic_id") WHERE "bar_record"."user_id" = 1

...但最终还是遇到了同样的问题。

不幸的是,ORM 生成的连接有一个基本限制,因为它们只能是以下形式...

(LEFT OUTER|INNER) JOIN <lhs_alias> ON (<lhs_alias>.<lhs_join_col> = <rhs_alias>.<rhs_join_col>)

...所以除了使用原始查询之外,真的没有办法实现您想要的 SQL。

当然,您可以使用 annotate()extra() 之类的东西,但它们可能会生成性能远低于原始 SQL 的查询,并且可以说不比原始​​ SQL 更具可读性。


...以及一个建议的替代方案。

就我个人而言,我只会使用原始查询...

select * from bar_topic
left join bar_record
on bar_record.topic_id = bar_topic.id and bar_record.user_id = 1

...这很简单,可以兼容所有 Django 支持的后端。

【讨论】:

  • 这基本上是我找到的关于 Query 内部工作原理的唯一文档(代码中的 cmets 除外),所以谢谢你的回答
【解决方案3】:

这个受trinchet's answer启发的更通用的解决方案也适用于其他数据库:

>>> qs = Topic.objects.annotate(
...         f=Max(Case(When(record__user=johnny, then=F('record__value'))))
... )

示例数据

>>> print(qs.values_list('name', 'f'))
[(u'A', 1), (u'B', None), (u'C', 3)]

验证查询

>>> print(qs.query)  # formated and removed excessive double quotes
SELECT bar_topic.id, bar_topic.name,
       MAX(CASE WHEN bar_record.user_id = 1 THEN bar_record.value ELSE NULL END) AS f
FROM bar_topic LEFT OUTER JOIN bar_record ON (bar_topic.id = bar_record.topic_id)
GROUP BY bar_topic.id, bar_topic.name

优势(与原始解决方案相比)

  • 它也适用于 SQLite。
  • 无论如何,查询集都可以轻松过滤或排序。
  • 不需要类型转换output_field
  • valuesvalues_list(*field_names) 方法对于更简单的GROUP BY 很有用,但它们不是必需的。

左连接可以通过写一个函数来提高可读性:

from django.db.models import Max, Case, When, F

def left_join(result_field, **lookups):
    return Max(Case(When(then=F(result_field), **lookups)))

>>> Topic.objects.annotate(
...         record_value=left_join('record__value', record__user=johnny),
... ).values_list('name', 'record_value')

更多来自 Record 的字段可以通过anotate 方法添加到具有良好助记符名称的结果中。

我同意其他作者可以优化的观点,但是readability counts

编辑:如果将聚合函数Max 替换为Min,则会出现相同的结果。 Min 和 Max 都忽略 NULL 值,并且可以用于任何类型,例如对于字符串。如果不能保证左连接是唯一的,则聚合很有用。如果字段是数字,则在左连接上使用平均值 Avg 会很有用。

【讨论】:

    【解决方案4】:

    原始查询。

    topics = Topic.objects.raw('''
                select * from bar_topic
                left join (select topic_id as tid, value from bar_record where user_id = 1) AS subq
                on tid = bar_topic.id
                ''')
    

    您似乎自己也知道答案。当您无法让 ORM 查询完全按照您希望的方式运行时,使用原始查询没有任何问题。

    原始查询的一个主要缺点是它们不像 ORM 查询那样被缓存。这意味着如果您遍历原始查询集两次,查询将被重复。另一个是你不能调用 .count() 。

    空外键

    您可以通过在外键中设置 null=True 来强制 ORM 使用 LEFT OUTER JOIN。照原样处理表格。

    print Record.objects.filter(user_id=8).select_related('topic').query
    

    结果是

    SELECT "bar_record"."id", "bar_record"."user_id", "bar_record"."topic_id", "bar_record"."value", "bar_topic"."id", "bar_topic"."name" FROM "bar_record"
    INNER JOIN "bar_topic" ON ( "bar_record"."topic_id" = "bar_topic"."id" ) WHERE "bar_record"."user_id" = 8
    

    现在设置 null=True 并执行与上面相同的 ORM 查询。结果是

    SELECT "bar_record"."id", "bar_record"."user_id", "bar_record"."topic_id", "bar_record"."value", "bar_topic"."id", "bar_topic"."name" FROM "bar_record" 
    LEFT OUTER JOIN "bar_topic" ON ( "bar_record"."topic_id" = "bar_topic"."id" ) WHERE "bar_record"."user_id" = 8
    

    注意查询是如何突然变为LEFT OUTER JOIN。但是我们还没有走出困境,因为桌子的顺序应该颠倒过来!因此,除非您可以重组您的模型,否则如果没有您已经尝试过的链接或 UNION,ORM LEFT OUTER JOIN 可能无法完全实现。

    【讨论】:

    • 这个左连接没有问题,您现在需要在这个查询中使用右连接。每个记录都有一个主题,但不是每个主题都有一个记录。
    • 正是@hynekcer,这也是我想要指出的
    • (对不起。20 年前我的主管说,如果有足够的公式,他可以读一本西班牙书。:-) 现在我只读了公式。)昨天我通过 django 进行了 grep。 db 用于左连接、外连接和相关的内部结构 o tre 查询。结果是只有两种情况会导致 OUTER (left/right) JOIN。它是具有 null=True 或 OR 条件的外键,因为如果相关表通过内连接连接,则条件 foreignkey_a.name='A' OR foreignkey_b.name='B' 可能会给出无效结果。 @trinchet 的解决方案是基于 OR 类型的逻辑。
    【解决方案5】:

    我会这样做。两个查询,不是一个:

    class Topic(models.Model):
        #...
    
        @property
        def user_value(self):
            try:
                return self.user_records[0].value
            except IndexError:
                #This topic does not have 
                #a review by the request.user
                return None
            except AttributeError:
                raise AttributeError('You forgot to prefetch the user_records')
                #or you can just
                return None
    
    #usage
    topics = Topic.objects.all().prefetch_related(
        models.Prefetch('record_set',
            queryset=Record.objects.filter(user=request.user),
            to_attr='user_records'
        )
    )
    
    for topic in topics:
        print topic.user_value
    

    好处是你得到了整个Record 对象。因此,考虑一种情况,您不仅要显示value,还要显示time-stamp

    为了记录,我想展示另一个使用.extra 的解决方案。让我印象深刻的是没有人提到它,因为它应该产生最好的性能。

    topics = Topic.objects.all().extra(
        select={
            'user_value': """SELECT value FROM myapp_record 
                WHERE myapp_record.user_id = %s
                AND myapp_record.topic_id = myapp_topic.id 
            """
        },
        select_params=(request.user.id,)
    )
    
    for topic in topics
        print topic.user_value
    

    这两种解决方案都可以抽象成一个自定义的TopicQuerySet 类以实现可重用性。

    class TopicQuerySet(models.QuerySet):
    
        def prefetch_user_records(self, user):
            return self.prefetch_related(
                models.Prefetch('record_set',
                    queryset=Record.objects.filter(user=request.user),
                    to_attr='user_records'
                )
            )
    
        def annotate_user_value(self, user):
            return self.extra(
                select={
                    'user_value': """SELECT value FROM myapp_record 
                        WHERE myapp_record.user_id = %s
                        AND myapp_record.topic_id = myapp_topic.id 
                    """
                },
                select_params=(user.id,)
            )
    
    class Topic(models.Model):
        #...
    
        objects = TopicQuerySet.as_manager()
    
    
    #usage
    topics = Topic.objects.all().annotate_user_value(request.user)
    #or
    topics = Topic.objects.all().prefetch_user_records(request.user)
    
    for topic in topics:
        print topic.user_value
    

    【讨论】:

    • 我喜欢你对Prefetch 的建议,但很遗憾,这需要另一个查询,而user_records 将是一个QuerySet,人们可以预期它只是一个Record(虽然问题中没有提到UNIQUE 约束)。
    【解决方案6】:

    Django 2.0 引入了FilteredRelation objects,我相信这就是你想要的。这个

    print('\nnew orm\n---')
    with self.assertNumQueries(1):
        topics = Topic.objects.annotate(
            filtered_record=FilteredRelation('record', condition=Q(record__user_id=1)),
        ).values_list('name', 'filtered_record__value')
    
        for topic in topics:
            print(*topic)
    

    产生预期的表格:

    new orm
    ---
    A 1
    B None
    C 3
    

    Django 输出的查询:

    SELECT "bar_topic"."name", filtered_record."value" FROM "bar_topic" LEFT OUTER JOIN "bar_record" filtered_record ON ("bar_topic"."id" = filtered_record."topic_id" AND (filtered_record."user_id" = 1))
    

    【讨论】:

    • 老兄,你救了我。
    【解决方案7】:

    将其留在这里,因为它解决了我们的问题,并且可能对其他人有所帮助。

    • 我们有两个表,我们称它们为 Client 和 Contract。
    • 客户端通过外键(多对一)引用合约
    • 对于某些客户记录,合同记录已被删除
    • 我们希望找到引用不再存在的合同的客户

    我们一开始使用OuterRef,但Client 和Contract 都是非常大的表。 OuterRef 被翻译成WHERE EXISTS,速度非常慢。上述许多选项也对我们不起作用。这就是我们所做的。

    from django.db.models.sql.constants import LOUTER
    
    queryset = Client.objects.filter(contract__date=None)
    queryset.query.alias_map['contract'].join_type = LOUTER
    

    运行 print(str(queryset.query)) 确实会产生一个带有左外连接的查询,结果也符合预期。

    结束观察:

    • contract__id=None 不起作用,因为 Django 太聪明了,它只检查子表上的 contract_id=None,这不是您要查找的孤立记录。我们在父表中选择了一个 NOT NULL 字段(在我们的示例中为 contract__date),强制 Django 进行连接。
    • 检查 alias_map 以了解 Django 如何命名您的别名。

    【讨论】:

      猜你喜欢
      • 2016-01-01
      • 1970-01-01
      • 2014-11-07
      • 2011-10-01
      • 1970-01-01
      • 2016-10-02
      • 1970-01-01
      • 2013-11-30
      • 2011-09-10
      相关资源
      最近更新 更多