【问题标题】:Annotating a Django queryset with a left outer join?用左外连接注释 Django 查询集?
【发布时间】:2011-09-23 21:52:24
【问题描述】:

假设我有一个模型:

class Foo(models.Model):
    ...

以及另一个基本上提供每个用户关于Foo的信息的模型:

class UserFoo(models.Model):
    user = models.ForeignKey(User)
    foo = models.ForeignKey(Foo)
    ...

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

我想生成一个 Foos 的查询集,但基于 user=request.user 使用(可选)相关的 UserFoo 进行注释。

所以它实际上是LEFT OUTER JOIN on (foo.id = userfoo.foo_id AND userfoo.user_id = ...)

【问题讨论】:

  • 可能性是两个查询:UserFoo.objects.filter(user=request.user).select_related("foo") 然后Foo.objects.exclude(userfoo__user=request.user) 但正在寻找其他可能性
  • 最终目标是什么/用例是什么?
  • @Dan 我希望从问题中可以清楚地看到:UserFoo 包含有关 Foo 的每个用户信息,我想显示一个 Foo 的列表,该列表用 UserFoo 的 request.user 的用户信息注释
  • 为什么在上面的查询中使用exclude 而不是filter?查找与用户相关的Foos 不是重点吗? “注释”是什么意思?您在这里的使用令人困惑,因为 Django 对注释有一个非常具体的定义,特别是“描述要计算的聚合”。
  • exclude 的原因是UserFoo 过滤器已经给了我与用户相关的过滤器;我想要与用户无关的Foos,因为它是左外连接

标签: django


【解决方案1】:

raw 的解决方案可能看起来像

foos = Foo.objects.raw("SELECT foo.* FROM foo LEFT OUTER JOIN userfoo ON (foo.id = userfoo.foo_id AND foo.user_id = %s)", [request.user.id])

您需要修改 SELECT 以包含来自 userfoo 的额外字段,这些字段将被注释到查询集中生成的 Foo 实例。

【讨论】:

  • 你将如何选择你想要的 UserFoo 字段?
  • 你如何避免列名冲突?
  • 我特别选择不从userfoo 中选择任何内容,以防止id 字段出现明显的名称冲突。如果您需要来自userfoo 的字段,您可以将查询修改为SELECT foo.*, userfoo.columnA FROM foo LEFT OUTER JOIN userfoo ON (foo.id = userfoo.foo_id AND foo.user_id = %s) 并以foos[0].columnA 访问
  • 左内连接示例companies = CompanyInfo.objects.raw("SELECT company.*, financials.last_trade_price FROM financials_companyinfo as company JOIN financials_marketdata as financials ON company.ticker=financials.ticker")
【解决方案2】:

这个答案可能不是你要找的,但因为它是谷歌搜索“django annotate external join”时的第一个结果,所以我会在这里发布。

注意:在 Djang 1.7 上测试

假设你有以下模型

class User(models.Model):
    name = models.CharField()

class EarnedPoints(models.Model):
    points = models.PositiveIntegerField()
    user = models.ForeignKey(User)

要获得总用户积分,您可能会这样做

 User.objects.annotate(points=Sum("earned_points__points"))

这会起作用,但它不会返回没有积分的用户,这里我们需要外连接,而不需要任何直接黑客或原始 sql

你可以这样做

 users_with_points = User.objects.annotate(points=Sum("earned_points__points"))
 result = users_with_points | User.objects.exclude(pk__in=users_with_points)

这将被翻译成 OUTER LEFT JOIN 并返回所有用户。没有积分的用户将在他们的积分属性中具有None 值。

希望有帮助

【讨论】:

  • 到目前为止,这应该是公认的答案!你应该得到加分!这就像大海捞针!非常感谢!
【解决方案3】:

注意:此方法在 Django 1.6+ 中不起作用。正如 tcarobruce 的 comment below 中所述,promote 参数已作为 ticket #19849: ORM Cleanup 的一部分被删除。


Django 没有提供完全内置的方法来执行此操作,但没有必要构造一个完全原始的查询。 (此方法不适用于从UserFoo 中选择*,因此我使用.comment 作为示例字段来包含从UserFoo 中。)

QuerySet.extra() method 允许我们向查询的 SELECT 和 WHERE 子句添加术语。我们使用它在我们的结果中包含来自UserFoo 表的字段,并将我们的UserFoo 匹配限制为当前用户。

results = Foo.objects.extra(
    select={"user_comment": "UserFoo.comment"},
    where=["(UserFoo.user_id IS NULL OR UserFoo.user_id = %s)"],
    params=[request.user.id]
)

这个查询仍然需要UserFoo 表。可以使用.extras(tables=...) 来获得隐式INNER JOIN,但是对于OUTER JOIN,我们需要自己修改内部查询对象。

connection = (
    UserFoo._meta.db_table, User._meta.db_table,  # JOIN these tables
    "user_id",              "id",                 # on these fields
)

results.query.join(  # modify the query
    connection,      # with this table connection
    promote=True,    # as LEFT OUTER JOIN
)

我们现在可以评估结果。每个实例都有一个.user_comment 属性,其中包含来自UserFoo 的值,如果不存在,则为None

print results[0].user_comment

(感谢 Colin Copeland 的 this blog post 向我展示了如何进行 OUTER JOIN。)

【讨论】:

  • 我收到了join() got an unexpected keyword argument 'promote'。更新版本的 Django 中是否删除了此功能?
  • promote 关键字作为ORM Cleanup 的一部分被删除。在 Django 1.6 中不再可用。
【解决方案4】:

我偶然发现了这个不使用原始 SQL 就无法解决的问题,但我不想重写整个查询。

以下描述了如何使用外部原始 sql 扩充查询集,而不必关心生成查询集的实际查询。

这是一个典型的场景:您有一个类似 reddit 的网站,其中包含 LinkPost 模型和 UserPostVote 模式,如下所示:

class LinkPost(models.Model):
some fields....

class UserPostVote(models.Model):
    user = models.ForeignKey(User,related_name="post_votes")
    post = models.ForeignKey(LinkPost,related_name="user_votes")
    value = models.IntegerField(null=False, default=0)

userpostvote 表在哪里收集用户对帖子的投票。 现在,您尝试使用分页应用程序为用户显示首页,但您希望用户投票的帖子的箭头为红色。

首先你会得到页面的帖子:

post_list = LinkPost.objects.all()
paginator = Paginator(post_list,25)
posts_page = paginator.page(request.GET.get('page'))

所以现在你有一个由 django 分页器生成的 QuerySet posts_page,它可以选择要显示的帖子。我们现在如何在每个帖子上添加用户投票的注释,然后再将其呈现在模板中?

这就是它变得棘手的地方,我无法找到一个干净的 ORM 解决方案。 select_related 将不允许您仅获得与登录用户相对应的投票,并且在帖子上循环会进行一堆查询而不是一个查询,并且所有这些都是原始的意思,我们不能使用分页应用程序中的查询集。

所以我是这样做的:

q1 = posts_page.object_list.query  # The query object of the queryset
q1_alias = q1.get_initial_alias()  # This forces the query object to generate it's sql
(q1str, q1param) = q1.sql_with_params() #This gets the sql for the query along with 
                                        #parameters, which are none in this example

我们现在有了查询集的查询,只需将其包装、别名和左外连接:

q2_augment = "SELECT B.value as uservote, A.* 
from ("+q1str+") A LEFT OUTER JOIN reddit_userpostvote B 
ON A.id = B.post_id AND B.user_id = %s"
q2param = (request.user.id,)
posts_augmented = LinkPost.objects.raw(q2_augment,q1param+q2param)

瞧!现在我们可以访问 post.uservote 以获取增强查询集中的帖子。 我们只用一个查询就可以访问数据库。

【讨论】:

    【解决方案5】:

    您建议的两个查询与您将获得的一样好(不使用 raw()),这种类型的查询目前无法在 ORM 中表示。

    【讨论】:

    • 一个不错的答案是可以接受的;不过,它不仅仅是“使用原始”。
    • 你能在 Django 中使用 ORM 进行外连接吗?
    【解决方案6】:

    您可以使用 simonw 的 django-queryset-transform 来避免硬编码原始 SQL 查询 - 代码如下所示:

    def userfoo_retriever(qs):
        userfoos = dict((i.pk, i) for i in UserFoo.objects.filter(foo__in=qs))
        for i in qs:
            i.userfoo = userfoos.get(i.pk, None)
    
    for foo in Foo.objects.filter(…).tranform(userfoo_retriever):
        print foo.userfoo
    

    这种方法非常成功地满足了这一需求并有效地检索了 M2M 值;您的查询计数不会那么低,但在某些数据库(cough MySQL cough)上,执行两个更简单的查询通常比使用复杂 JOIN 的一个查询要快,而且许多在我最需要它的情况下,它具有额外的复杂性,更难破解 ORM 表达式。

    【讨论】:

    • 作为保持迭代器模式完整的替代方案,我创建了一个名为 github.com/vdboor/django-queryset-decorator 的分支,这在某些边缘情况下也可能有用
    • vdboor:我喜欢 django-queryset-transform 的全部功能,但对于没有额外灵活性的简单情况,这似乎是一个很好的节省时间。
    【解决方案7】:

    关于外连接: 一旦你有一个来自 foo 的查询集 qs 包含对来自 userfoo 的列的引用,你可以将内连接提升为外连接 qs.query.promote_joins(["userfoo"])

    【讨论】:

      【解决方案8】:

      您不必为此求助于extraraw

      以下应该可以工作。

      Foo.objects.filter(
          Q(userfoo_set__user=request.user) |
          Q(userfoo_set=None)  # This forces the use of LOUTER JOIN.
      ).annotate(
          comment=F('userfoo_set__comment'),
          # ... annotate all the fields you'd like to see added here.
      )
      

      【讨论】:

      • 这不起作用。这可能会创建一个连接,但连接可能会出现在具有错误 user 值的 UserFoo 记录上,然后会被过滤掉。这是因为 user 条件是在您的 WHERE 子句中创建的,而不是在 ON 连接条件中。
      【解决方案9】:

      我认为不使用raw 等的唯一方法是这样的:

      Foo.objects.filter(
          Q(userfoo_set__isnull=True)|Q(userfoo_set__isnull=False)
      ).annotate(bar=Case(
          When(userfoo_set__user_id=request.user, then='userfoo_set__bar')
      ))
      

      双重Q 技巧可确保您获得左外连接。

      不幸的是,您不能在 filter() 中设置您的 request.user 条件,因为它可能会过滤掉与错误用户的 UserFoo 实例的成功连接,从而过滤掉您想要保留的 Foo 行(这就是为什么您理想情况下,需要 ON 连接子句中的条件,而不是 WHERE 子句中的条件。

      因为您无法过滤掉具有不需要的 user 值的行,所以您必须从 UserFoo 中选择带有 CASE 的行。

      另请注意,一个 Foo 可能会加入许多 UserFoo 记录,因此您可能需要考虑某种方法从输出中检索不同的 Foo。

      【讨论】:

        【解决方案10】:

        maparent 的评论让我走上了正确的道路:

        from django.db.models.sql.datastructures import Join
        
        for alias in qs.query.alias_map.values():
          if isinstance(alias, Join):
            alias.nullable = True
        
        qs.query.promote_joins(qs.query.tables)
        

        【讨论】:

          猜你喜欢
          • 2017-06-30
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2017-11-27
          • 2019-10-13
          • 2011-08-14
          • 1970-01-01
          相关资源
          最近更新 更多