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
模型,用于记录教程类文章。现在有一个需求,需要查询出全部的 Post
和 Material
,并以文章发表时间 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']
可以看到 Material
比 Post
少了一个 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_qs
的 query
属性,这个 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()))
type
和 pinned
顺序无法确定,导致 SQL 查询中,UNION
后的那条查询教程类文章的 select 语句中 type
和 pinned
字段顺序无法确定,导致字段不匹配。
解决办法也很简单,按顺序使用 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
查询中先 annotate
了 pinned
字段,然后再是 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
操作),这样可以将两条查询语句合并为一条,减少数据库的查询次数,同时还能在数据库层面对组合的数据进行排序等操作。但使用时要注意:
- select 的字段类型必须匹配(字段名可以不同,但排列顺序要一致)
- 确保
annotate
方法设置的查询字段顺序一致 - 合并后的查询集,很多方法将不可用
- 待合并的查询集不能有排序操作
- 合并后的查询集不能对
annotate
设置的字段使用 F 表达式 - 合并后的查询集排序时不能指定 null 的顺序
-- EOF --
学习一个