追梦人物❤️包子 博主
一直走在追梦的路上。

Django 使用 union 合并不同模型(Model) 的查询集(QuerySet)

2019-10-2311206 阅读1 评论

Django 开发中有时候会遇到这样的需求:查询到不同模型(Model) 的查询集(QuerySet),需要将其合并成一个查询集,甚至还希望能够对合并后的查询集排序,以便在模板中循环展示。

一个直观的想法就是将多个查询集合并为一个列表,然后使用 Python 的 sorted 方法排序,类似于:

>>> import itertools
>>> qs1 = Post.objects.all()
>>> qs2 = Material.objects.all()
>>> qs = itertools.chain(qs1, sq2)
>>> sorted(qs, key=lambda o: o.created_time, reverse=True)

但是这种方法需要额外遍历两个 QuerySet,而且排序在 Python 层面进行,会损失一些性能。在对查询到的数据进行操作时的一个重要原则是尽可能在最底层完成操作。例如尽量在数据库层面进行数值计算或者排序等操作,数据库无法完成操作时再上升到 Python 层面。那么使用 Django 的 ORM 有没有办法同时查询出多个模型的数据并对其进行计算或者排序呢?答案是使用查询集的 union 方法。

QuerySet 的 union 方法

union 方法其实对应数据库的 UNION 操作。以我的博客为例(源代码位于 django-blog-project),博客有 2 个 app,其中一个 app 中有一个 Post 模型,用于记录普通类型的博客文章,另一个 app 中有一个 Material 模型,用于记录教程类文章。现在有一个需求,需要查询出全部的 PostMaterial,并以文章发表时间 pub_date 逆序排序(但置顶的普通类型文章必须排在最前面)用于博客首页文章列表展示。2 个模型定义分别定义如下(适当简化,完整定义请参考源码):

from django.db import models

class Post(modes.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()
    pub_date = models.DateTimeField()
    pinned = models.BooleanField(default=False)

    class Meta:
        ordering = ['-pinned', '-pub_date']

class Material(models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()
    pub_date = models.DateTimeField()

    class Meta:
        ordering = ['-pub_date']

可以看到 MaterialPost 少了一个 pinned 字段,pinned 字段用于标识文章是否置顶。首页文章展示需要查出除了 body 外的全部字段。ORM 的查询代码如下:

def get_index_entry_queryset():
    post_qs = Post.objects.all().order_by().annotate(
        type=Value('p', output_field=CharField(max_length=1)),
        entry_pinned=F('pinned'))
    post_qs = post_qs.values_list(
        'title','pub_date','entry_pinned','type'
    )

    material_qs = Material.objects.all().order_by().annotate(
        type=Value('m', output_field=CharField(max_length=1)),
        entry_pinned=Value(False, BooleanField()))
    material_qs = material_qs.values_list(
        'title','pub_date','entry_pinned','type'
    )

    entry_qs = post_qs.union(material_qs)
    entry_qs = entry_qs.order_by('-entry_pinned', '-pub_date')
    return entry_qs

除了查询出模型已有字段,还使用 annotate 设置了额外的查询字段,type 用于标识博客文章类型,Material 模型没有 pinned 字段,因此使用 annotate 设置了一个 entry_pinned 字段,其值恒定为 False,同时还对 Post 模型的 pinned 字段名使用 annotate 进行了别名设置,pinned 字段设置别名的具体原因会在后面说。

查看 entry_qsquery 属性,这个 ORM 查询实际执行的 SQL 语句如下:

SELECT "blog_post"."title", "blog_post"."pub_date", 'p' AS "type", "blog_post"."pinned" AS "entry_pinned"
FROM "blog_post"
UNION
SELECT "courses_material"."title", "courses_material"."pub_date", 'm' AS "type", False AS "entry_pinned"
FROM "courses_material"
ORDER BY (4) DESC, (2) DESC

数据库查询结果如下:

title pub_date type entry_pinned
Markdown 测试 2019-09-23 15:35:47.898271 p 1
test 2019-09-15 13:13:00 p 0
分类、归档和标签页 2019-09-07 01:41:00 m 0
页面侧边栏:使用自定义模板标签 2019-08-29 23:49:00 m 0

Django 默认使用 UNION 操作,这会去除重复记录,保留重复记录可以给 union 方法传入 all=True,这将使用 UNION ALL 操作。

注意事项

显然,要将两个不同模型的查询集合并为一个查询集,会有一些限制条件,因为涉及数据库的 UNION 操作,至少要保证两个模型查询出来的字段和类型都匹配。下面是 Django 的官方文档给出的 union 方法使用限制。

  • select 的字段类型必须匹配(字段名可以不同,但排列顺序要一致)。例如 field1 和 field 2 都是整数类型,select field1 和 select field 可以进行 union 操作,当引用时,以第一个 QuerySet 中的字段名进行引用。
  • 组合后的查询集,很多方法将不可用。

不过在实际使用过程中,发现还用很多的未提及的限制需要小心翼翼地处理。

例如看到示例中的这两句代码:

post_qs = Post.objects.all().order_by().annotate(
        type=Value('p', output_field=CharField(max_length=1)),
        entry_pinned=F('pinned'))
material_qs = Material.objects.all().order_by().annotate(
        type=Value('m', output_field=CharField(max_length=1)),
        entry_pinned=Value(False, BooleanField()))

代码中调用了 order_by() 取消模型的默认排序(模型在 Meta 通过 ordering 选项指定了默认排序),如果不这样做,将得到一个异常:

django.db.utils.DatabaseError: ORDER BY not allowed in subqueries of compound statements.

另外还要注意 annotate 的使用,尽管 Post 模型定义了 pinned 字段,可以直接进行查询,但是这种情况下必须要使用 annotate(对应数据库中的 as 别名)对 pinned 字段取一个别名,因为 Material 模型没有这个字段,但在查询时设置了一个固定值的别名(为了保证查询字段的个数、顺序和类型三者一致)。

有的同学可能想这样做:

post_qs = Post.objects.all().annotate(
    type=Value('p', output_field=CharField(max_length=1)))
post_qs = post_qs.values_list(
        'title','pub_date','pinned','type'
    )

material_qs = Material.objects.all().annotate(
    type=Value('m', output_field=CharField(max_length=1)),
    pinned=Value(False, BooleanField()))
material_qs = material_qs.values_list(
        'title','pub_date','pinned','type'
    )

Post 模型直接通过 values_list 选择需要的字段。看上去两个模型通过 values_list 方法 select 的字段数量、顺序、类型都相同,但实际上 Django 执行的 SQL 却是:

SELECT "blog_post"."title", "blog_post"."pub_date", "blog_post"."pinned", 'p' AS "type"
FROM "blog_post"
UNION
SELECT "courses_material"."title", "courses_material"."pub_date", 'm' AS "type", False AS "pinned"
FROM "courses_material"
ORDER BY (4) DESC, (2) DESC

注意这里 pinned 的顺序不匹配了,这会导致字段顺序错乱,字段值错位,得不到想要的结果。其原因就是 annotate 的字段顺序不匹配。annotate 方法传入的关键字参数会被收集为字典,而字典是无序的,所以看到这段代码:

material_qs = Material.objects.all().annotate(
    type=Value('m', output_field=CharField(max_length=1)),
    pinned=Value(False, BooleanField()))

typepinned 顺序无法确定,导致 SQL 查询中,UNION 后的那条查询教程类文章的 select 语句中 typepinned 字段顺序无法确定,导致字段不匹配。

解决办法也很简单,按顺序使用 annotate 即可:

post_qs = Post.objects.all().annotate(
    type=Value('p', output_field=CharField(max_length=1)))
post_qs = post_qs.values_list(
        'title','pub_date','pinned','type'
    )

material_qs = Material.objects.all().annotate(
    pinned=Value(False, BooleanField())).annotate(
    type=Value('m', output_field=CharField(max_length=1)))
material_qs = material_qs.values_list(
        'title','pub_date','pinned','type'
    )

注意这里 material_qs 查询中先 annotatepinned 字段,然后再是 type 字段,这样在省掉对 pinned 字段设置别名的同时又保持了字段顺序的一致。

还有看到生成的 SQL 语句中的 ORDER BY 子句,Django 使用了查询字段的位置而不是字段名引用待排序的字段。ORM 中无法使用 F 表达式对 annotate 设置的字段进行排序,以及对模型字段,F 表达式排序方法不能设置 null_first 或者 nulls_last 参数,以下用法都会报错:

entry_qs = entry_qs.order_by(F('type').desc(), '-pub_date')
entry_qs = entry_qs.order_by('-pinned', F('pub_date').desc(nulls_last=True))

将得到如下错误:

django.db.utils.DatabaseError: ORDER BY term does not match any column in the result set.

然而对于实际的 SQL 语句,使用 as 设置的别名字段是可以进行排序的:

SELECT "blog_post"."title", "blog_post"."pub_date", "blog_post"."pinned", 'p' AS "type"
FROM "blog_post"
UNION
SELECT "courses_material"."title", "courses_material"."pub_date", False AS "pinned", 'm' AS "type"
FROM "courses_material"
ORDER BY type DESC, pub_date DESC

直接执行这条查询语句可以得到正确的查询结果,不知道为何 django 会报错。

总结

查询集的 union 方法可以将不同模型查询结果合并为一个查询集(使用数据库的 UNION 操作),这样可以将两条查询语句合并为一条,减少数据库的查询次数,同时还能在数据库层面对组合的数据进行排序等操作。但使用时要注意:

  1. select 的字段类型必须匹配(字段名可以不同,但排列顺序要一致)
  2. 确保 annotate 方法设置的查询字段顺序一致
  3. 合并后的查询集,很多方法将不可用
  4. 待合并的查询集不能有排序操作
  5. 合并后的查询集不能对 annotate 设置的字段使用 F 表达式
  6. 合并后的查询集排序时不能指定 null 的顺序

-- EOF --

1 评论
登录后回复
下_地_干_活
2020-06-28 18:53:38

学习一个

回复