Django ORM 合并查询集

在用python或者django写一些小工具应用的时候,有可能会遇到合并多个list到一个 list 的情况。单纯从技术角度来说,处理起来没什么难度,能想到的办法很多,但我觉得有一个很简单而且效率比较高的方法是我以前没注意到的。那就是利用 chain 方法来合并多个list. 同样也可以用来合并django 的 QuerySet.

1. 用 | 合并多个 QuerySet

1
2
3
4
5
6
7
8
9
10
11
12
# coding:utf-8

from itertools import chain
from yihaomen.common.models import Article

articles1 = Article.objects.order_by("autoid").filter(autoid__lt = 16).values('autoid','title')
articles2 = Article.objects.filter(autoid = 30).values('autoid','title')

articles = articles1 | articles2 # 注意这里采用的方式。如果 Model相同,而且没有用切片,并且字段一样时可以这样用
print articles1
print articles2
print articles

因为 Django ORM 存在惰性查询机制,所以 articles = articles1 | articles2 并未真正执行SQL,所以在真正查询时会对两个查询的语句进行合并, 导致sql结果排序和预期不同。

2. 在Django 总用 chain 合并多个QuerySet

chain 是用C实现的,自然性能上比较可靠。

如果在Django中如果要合并同一个model的多个 QuerySet 的话,是可以采用这种方式的.

用chain 来实现会更方便,也没那么多限制条件,即使是不同的MODEL中查询出来的数据,都可以很方便的合并到一个 list 中去.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# coding:utf-8

from itertools import chain
from yihaomen.common.models import Article, UserID

query1 = Article.objects.order_by("autoid").filter(autoid__lt = 16).values('autoid','title')
query2 = UserID.objects.all()

items = chain(query1, query2)
for item in items:
print item

# 也可以排序
sort_quer = sorted(chain(query1, query2), key=lambda instance: instance.create_time) # 排序
print(sort_quer)

但是合并的结果是一个list,不是queryset。

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

3. QuerySet 的 union 方法

union 方法其实对应数据库的 UNION 操作。

Post 模型,用于记录普通类型的博客文章, Material 模型,用于记录教程类文章。现在有一个需求,需要查询出全部的 PostMaterial,并以文章发表时间 pub_date 逆序排序(但置顶的普通类型文章必须排在最前面)用于博客首页文章列表展示。2 个模型定义分别定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

除了查询出模型已有字段,还使用 `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`

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

> union 方法无法分别对两个 QuerySet 进行排序操作(order_by),可采用如下代码

def queryset_2_union_offset_limit(queryset_1, queryset_2, offset, limit):
“””
QuerySet union
:param queryset_1:
:param queryset_2:
:param offset:
:param limit:
:return:
“””
count_1 = queryset_1.count()
count_need_return = offset + limit

if offset + limit <= count_1:
    return queryset_1[offset:count_need_return]

if offset < count_1:
    ret = []
    for i in queryset_1[offset:count_need_return]:
        ret.append(i)

    len_ret = len(ret)
    if len_ret < count_need_return:
        for i in queryset_2[:count_need_return - len_ret]:
            ret.append(i)
    return ret

if offset >= count_1:
    return queryset_2[offset - count_1: count_need_return - count_1]

#### 注意事项

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

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

#### 总结

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

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

Django ORM 合并查询集
https://flepeng.github.io/021-Python-32-框架-Django-Django-ORM-合并查询集/
作者
Lepeng
发布于
2021年8月8日
许可协议