Peewee 查询与 CRUD 操作完全指南:从 INSERT 到窗口函数与 CTE

  • 后端
  • ORM
  • 数据库

【免费下载链接】peewee

a small, expressive orm -- supports postgresql, mysql, sqlite, now with asyncio

项目地址: https://gitcode.com/gh_mirrors/pe/peewee
点击查看 免费下载

导读

本文是 Peewee(a small, expressive ORM,支持 PostgreSQL、MySQL、SQLite 及 asyncio)官方文档中 docs/peewee/querying.rst 的完整技术解读。它系统覆盖了关系型数据库中最常用的增删改查(CRUD)操作,以及批量写入、原子更新、Upsert、聚合、窗口函数、RETURNING 子句和公共表表达式(CTE)等进阶查询能力。读完本文,你将掌握 Peewee 中从 Model.create 到递归 CTE 的全部查询 API 用法、底层行为(如缓存、事务、主键回填)与性能优化手段,并能直接照搬到自己的项目中使用。


一、概述:CRUD 操作全景

Peewee 的查询 API 以 Model 类方法为入口,覆盖四种基本 SQL 语句:

操作对应方法SQL 语句
创建记录Model.create、Model.save、Model.insert、Model.insert_many、Model.bulk_createINSERT
更新记录Model.save、Model.update、Model.bulk_updateUPDATE
删除记录Model.delete_instance、Model.deleteDELETE
查询记录Model.select、Model.get、Model.get_by_idSELECT

此外,官方还提供了大量取自 PostgreSQL Exercises;运算符的完整清单参见 docs/peewee/query_operators.rst。


二、创建新记录

2.1 使用 Model.create

Model.create 接收关键字参数,参数名对应模型的字段名。执行后返回一个新的模型实例,同时向表中插入一行:

>>> User.create(username='Charlie')
<__main__.User object at 0x2529350>

该操作会执行一条 INSERT,并且新行的主键会被自动检索并回填到返回的实例上。

从源码看,create 的实现非常直接(见 peewee.py):

@classmethod
def create(cls, **query):
    inst = cls(**query)
    inst.save(force_insert=True)
    return inst

它本质上是"构造实例 + 强制 INSERT 的 save()"两步的语法糖。

2.2 先构造实例再 save()

你也可以用程序化方式构造模型实例,然后调用 save()。save() 返回受影响的行数:

>>> user = User(username='Charlie')
>>> user.save()  # save() 返回被修改的行数。
1
>>> user.id
1
>>> huey = User()
>>> huey.username = 'Huey'
>>> huey.save()
1
>>> huey.id
2

save() 的完整行为见 peewee.py:当主键为 None 且未强制插入时执行 INSERT 并回填自增主键;当主键已存在时执行 UPDATE。

2.3 外键赋值

当模型包含外键字段时,可以直接把模型实例赋给外键字段:

>>> tweet = Tweet.create(user=huey, message='Hello!')

也可以直接使用关联对象的主键值:

>>> tweet = Tweet.create(user=2, message='Hello again!')

2.4 纯插入:Model.insert

如果只是想插入数据而不需要模型实例,可以使用 Model.insert,执行后返回新行的主键:

>>> User.insert(username='Mickey').execute()
3

注意:如果你需要提升批量插入的速度,请参考本文"批量插入"一节。Peewee 也提供 Model.replace 等插入变体,见"Upsert"一节。


三、批量插入(Bulk Inserts)

3.1 最朴素(也是最慢)的方式

在循环里逐个调用 Model.create:

data_source = [
    {'field1': 'val1-1', 'field2': 'val1-2'},
    {'field1': 'val2-1', 'field2': 'val2-2'},
    # ...
]

for data_dict in data_source:
    MyModel.create(**data_dict)

这种方式慢的原因有四:

  1. 如果循环没有包在事务里,每次 create 都是独立事务,代价极高;
  2. 大量 Python 逻辑介入,且每条 InsertQuery 都要生成并解析为 SQL;
  3. 发送给数据库解析的 SQL 原始字节数巨大;
  4. 需要获取 last insert id,某些情况下会额外执行一次查询。

3.2 第一层优化:包进事务

使用 db.atomic() 把循环包进单个事务,能获得显著的性能提升:

# 这样快很多。
with db.atomic():
    for data_dict in data_source:
        MyModel.create(**data_dict)

不过上面仍然存在第 2、3、4 条问题。

3.3 第二层优化:insert_many

insert_many 接受一个字典列表(或元组列表),用单条查询插入多行:

data_source = [
    {'field1': 'val1-1', 'field2': 'val1-2'},
    {'field1': 'val2-1', 'field2': 'val2-2'},
    # ...
]

# 批量 INSERT 多行最快的写法。
MyModel.insert_many(data_source).execute()

若传入的是元组列表,则必须同时指定 fields 以说明每个值对应的字段:

# 我们也可以 INSERT 元组……
data = [('val1-1', 'val1-2'),
        ('val2-1', 'val2-2'),
        ('val3-1', 'val3-2')]

# 但需要指明这些值对应哪些字段。
MyModel.insert_many(data, fields=[MyModel.field1, MyModel.field2]).execute()

同样建议把批量插入包进事务:

# 当然,也可以包进一个事务:
with db.atomic():
    MyModel.insert_many(data, fields=fields).execute()

从源码看,insert_many(rows, fields=None) 只是把数据委托给 ModelInsert 构造器(见 peewee.py),最终仍走 execute() 执行。

SQLite 注意事项:SQLite 用户使用批量插入 API 时,SQLite 版本必须是 3.7.11.0 或更新。此外,SQLite 默认限制单条 SQL 查询的绑定变量数量:3.32.0(2020-05-22)之前的版本上限为 999,之后的版本为 32766。

3.4 分批插入(Batch Inserts)

当数据量很大时,需要把数据拆分成多个批次。SQLite 尤其受绑定变量数量限制(999 或 32766),因此批次大小大约是 999 // 行长 或 32766 // 行长。

可以手写循环分批(强烈建议包进事务):

# 每次插入 100 行。
with db.atomic():
    for idx in range(0, len(data_source), 100):
        MyModel.insert_many(data_source[idx:idx+100]).execute()

Peewee 自带 chunked 辅助函数,可以高效地把任意可迭代对象切成一批批的小迭代器:

from peewee import chunked

# 每次插入 100 行。
with db.atomic():
    for batch in chunked(data_source, 100):
        MyModel.insert_many(batch).execute()

chunked 的实现位于 peewee.py,它基于 izip_longest 把迭代器按固定长度分组:

def chunked(it, n):
    marker = object()
    for group in (list(g) for g in izip_longest(*[iter(it)] * n,
                                                fillvalue=marker)):
        if group[-1] is marker:
            del group[group.index(marker):]
        yield group

3.5 替代方案:bulk_create 与 bulk_update

Model.bulk_create 与 insert_many 行为类似,但它接受的是未保存的模型实例列表,并且可选 batch_size 参数:

# 例如,从一个文件中读取用户名列表。
with open('user_list.txt') as fh:
    # 构造一批未保存的 User 实例。
    users = [User(username=line.strip()) for line in fh.readlines()]

# 包进事务,每 100 个一批执行 INSERT。
with db.atomic():
    User.bulk_create(users, batch_size=100)

PostgreSQL 专属福利:PostgreSQL 支持 RETURNING 子句,因此使用 bulk_create 后,之前未保存的模型实例会自动获得新的主键值。这一逻辑在 peewee.py 的 bulk_create 实现中清晰可见:当数据库支持 returning_clause 且模型有主键时,会用返回的 pk_fields 逐行 setattr 回填实例主键。

Model.bulk_update 则可以高效地批量更新一个模型列表中的一个或多个列:

# 先创建 3 个用户 u1、u2、u3。
u1, u2, u3 = [User.create(username='u%s' % i) for i in (1, 2, 3)]

# 现在修改这些实例。
u1.username = 'u1-x'
u2.username = 'u2-y'
u3.username = 'u3-z'

# 用一条 UPDATE 更新全部三个用户。
User.bulk_update([u1, u2, u3], fields=[User.username])

生成的 SQL 大致如下:

UPDATE "users" SET "username" = CASE "users"."id"
    WHEN 1 THEN "u1-x"
    WHEN 2 THEN "u2-y"
    WHEN 3 THEN "u3-z" END
WHERE "users"."id" IN (1, 2, 3);

对于超长列表,建议指定合理的 batch_size 并用 Database.atomic() 包裹:

with database.atomic():
    User.bulk_update(list_of_users, fields=['username'], batch_size=50)

警告:bulk_update 可能不是更新大量记录最高效的方式——它的实现是为所有被更新行构造"主键 → 字段值"的映射,并使用 SQL CASE 语句完成(见 peewee.py)。若涉及大量数据,请评估其 SQL 体积。

另一个辅助工具是 Database.batch_commit,它以 batch 大小的事务逐块处理行数据,同时也为非 PostgreSQL 数据库获取新建行主键提供了变通方案:

# 待插入的行数据列表。
row_data = [{'username': 'u1'}, {'username': 'u2'}, ...]

# 假设 row_data 有 789 项,下面的代码会产生 8 个事务(7x100 行 + 1x89 行)。
for row in db.batch_commit(row_data, 100):
    User.create(**row)

其实现见 peewee.py:先 chunked 分组,再为每个分组开启 atomic() 事务并逐个 yield。

3.6 从另一张表批量装载:insert_from

如果数据已存在于另一张表,可以构造 源为 SELECT 查询 的 INSERT 语句,使用 Model.insert_from:

res = (TweetArchive
       .insert_from(
           Tweet.select(Tweet.user, Tweet.message),
           fields=[TweetArchive.user, TweetArchive.message])
       .execute())

等价 SQL:

INSERT INTO "tweet_archive" ("user_id", "message")
SELECT "user_id", "message" FROM "tweet";

四、更新已有记录

4.1 实例的 save() 更新语义

一旦模型实例拥有了主键,后续调用 save() 将执行 UPDATE 而非再次 INSERT,且主键保持不变:

>>> user.save()  # save() 返回被修改的行数。
1
>>> user.id
1
>>> user.save()
>>> user.id
1
>>> huey.save()
1
>>> huey.id
2

4.2 批量更新:Model.update

要一次更新多条记录,使用 Model.update。下面的例子会把创建日期早于今天的 Tweet 全部标记为已发布。update 同样接收关键字参数,键对应字段名:

>>> today = datetime.today()
>>> query = Tweet.update(is_published=True).where(Tweet.creation_date < today)
>>> query.execute()  # 返回被更新的行数。
4

详细说明可参考 Model.update、Update 与 Model.bulk_update 的文档。若需要原子更新(如对列自增),见下文。


五、原子更新(Atomic Updates)

Peewee 支持原子更新。假设需要更新某些计数器,朴素写法是这样的:

>>> for stat in Stat.select().where(Stat.url == request.url):
...     stat.counter += 1
...     stat.save()

千万不要这样做! 它不仅慢,而且在多进程同时更新计数器时存在竞态条件。

正确做法是使用 Model.update 在数据库端完成原子操作:

>>> query = Stat.update(counter=Stat.counter + 1).where(Stat.url == request.url)
>>> query.execute()

更新语句可以写得非常复杂。比如给所有员工发放奖金:原有奖金加上工资的 10%:

>>> query = Employee.update(bonus=(Employee.bonus + (Employee.salary * .1)))
>>> query.execute()  # 给每个人发奖金!

甚至可以用子查询更新列的值。假设 User 上有一个反规范化列 num_tweets 存储用户推文数,需要定期刷新:

>>> subquery = Tweet.select(fn.COUNT(Tweet.id)).where(Tweet.user == User.id)
>>> update = User.update(num_tweets=subquery)
>>> update.execute()

5.1 Upsert(插入或更新)

Peewee 提供多种 upsert 支持:

  • SQLite(3.24.0 之前)与 MySQL:支持 Model.replace——插入记录,若发生约束冲突则替换已有记录。
  • SQLite 3.24+ 与 PostgreSQL:完整支持 ON CONFLICT 查询。

Model.replace 与 Insert.on_conflict_replace 的等价用法:

class User(Model):
    username = TextField(unique=True)
    last_login = DateTimeField(null=True)

# 插入或更新用户。无论用户此前是否存在,"last_login" 都会被更新。
user_id = (User
           .replace(username='the-user', last_login=datetime.now())
           .execute())

# 下面的查询与之等价:
user_id = (User
           .insert(username='the-user', last_login=datetime.now())
           .on_conflict_replace()
           .execute())

此外,SQLite、MySQL 和 PostgreSQL 都支持 ignore 动作(见 Insert.on_conflict_ignore):只想插入并忽略潜在的约束冲突时使用。源码层面,replace() 等价于 insert(...).on_conflict('REPLACE')(见 peewee.py)。

MySQL 通过 ON DUPLICATE KEY UPDATE 子句支持 upsert:

class User(Model):
    username = TextField(unique=True)
    last_login = DateTimeField(null=True)
    login_count = IntegerField()

# 插入一个新用户。
User.create(username='huey', login_count=0)

# 模拟用户登录。登录次数和时间戳会被正确创建或更新。
now = datetime.now()
rowid = (User
         .insert(username='huey', last_login=now, login_count=1)
         .on_conflict(
             preserve=[User.last_login],  # 使用我们本要插入的值。
             update={User.login_count: User.login_count + 1})
         .execute())

上面这个 upsert 查询可以安全地反复执行:登录计数被原子地自增、最后登录时间被更新、不会产生重复行。

PostgreSQL 与 SQLite(3.24.0 及以上) 提供了不同语法,允许更精细地控制:指定哪个约束冲突触发解决逻辑、哪些值应更新或保留:

class User(Model):
    username = TextField(unique=True)
    last_login = DateTimeField(null=True)
    login_count = IntegerField()

# 插入一个新用户。
User.create(username='huey', login_count=0)

now = datetime.now()
rowid = (User
         .insert(username='huey', last_login=now, login_count=1)
         .on_conflict(
             conflict_target=[User.username],  # 哪个约束?
             preserve=[User.last_login],  # 使用我们本要插入的值。
             update={User.login_count: User.login_count + 1})
         .execute())

MySQL 与 PostgreSQL/SQLite 的主要区别在于:PostgreSQL 和 SQLite 必须指定 conflict_target。

再看一个使用 EXCLUDED 命名空间的进阶(略带构造色彩)例子。EXCLUDED 允许我们引用发生冲突的数据中的值。假设有一个把唯一键(字符串)映射为值(整数)的表:

class KV(Model):
    key = CharField(unique=True)
    value = IntegerField()

# 创建一行。
KV.create(key='k1', value=1)

# 演示 EXCLUDED 的用法。
# 这里我们尝试为某个 key 插入新值。如果该 key 已存在,
# 则用"原值 + 尝试插入的值"更新——前提是新值大于原值。
query = (KV.insert(key='k1', value=10)
         .on_conflict(conflict_target=[KV.key],
                      update={KV.value: KV.value + EXCLUDED.value},
                      where=(EXCLUDED.value > KV.value)))

# 执行后 "kv" 表中的数据将是:
# (key='k1', value=11)
query.execute()

# 如果再次执行该查询,则不会有任何更新,
# 因为新值(10)已经小于原行中的值(11)。

理解 ON CONFLICT 需要掌握四个核心概念:

  • conflict_target=:哪一列(或哪些列)有 UNIQUE 约束。对于用户表,可能是用户邮箱。
  • preserve=:发生冲突时,从新数据中取出哪些值用于更新。
  • update=:发生冲突时,应用到已存在行上的数据映射。
  • EXCLUDED:这个"魔法"命名空间允许你引用本应被插入、但因约束失败而未插入的新数据。

完整示例——一个登录函数,同时演示以上四个概念:

class User(Model):
    email = CharField(unique=True)  # 用户唯一标识。
    last_login = DateTimeField()
    login_count = IntegerField(default=0)
    ip_log = TextField(default='')


# 演示上述 4 个概念。
def login(email, ip):
    rowid = (User
             .insert({User.email: email,
                      User.last_login: datetime.now(),
                      User.login_count: 1,
                      User.ip_log: ip})
             .on_conflict(
                 # 如果 INSERT 因邮箱约束冲突失败,则执行 UPDATE。
                 conflict_target=[User.email],

                 # 把 "last_login" 设为我们本要插入的值(datetime.now())。
                 preserve=[User.last_login],

                 # 登录次数 +1,并把新 IP 拼到 ip 历史的最前面。
                 update={User.login_count: User.login_count + 1,
                         User.ip_log: fn.CONCAT(EXCLUDED.ip_log, ',', User.ip_log)})
             .execute())

    return rowid

# 插入初始行,返回新行 id(1)。
print(login('test@example.com', '127.1'))

# 因为 test@example.com 已存在,将触发 UPSERT。返回上面的行 id(1)。
print(login('test@example.com', '127.2'))

u = User.get()
print(u.login_count, u.ip_log)

# 输出 "2 127.2,127.1"

更详细的说明参见 Insert.on_conflict 与 OnConflict 的文档。


六、删除记录

6.1 删除单个实例:delete_instance

Model.delete_instance 删除指定模型实例,并可通过 recursive=True 递归删除依赖对象:

>>> user = User.get(User.id == 1)
>>> user.delete_instance()  # 返回被删除的行数。
1

>>> User.get(User.id == 1)
UserDoesNotExist: instance matching query does not exist:
SQL: SELECT t1."id", t1."username" FROM "user" AS t1 WHERE t1."id" = ?
PARAMS: [1]

源码实现见 peewee.py:当 recursive=True 时,通过 dependencies() 遍历所有反向引用;对于可空外键且未指定 delete_nullable 时,会把外键置空而非删除子行。

6.2 批量删除:Model.delete

删除任意一组行时,使用 DELETE 查询。下面的语句删除所有创建时间超过一年的 Tweet:

>>> query = Tweet.delete().where(Tweet.creation_date < one_year_ago)
>>> query.execute()  # 返回被删除的行数。
7

相关文档:Model.delete_instance、Model.delete、DeleteQuery。


七、查询单条记录

7.1 Model.get、get_by_id 与下标访问

Model.get 用于检索符合给定查询的单条实例;主键查询可用快捷方法 Model.get_by_id。get 是"先 select 再限制为单行"的快捷方式,且若无匹配则抛出 DoesNotExist 异常:

>>> User.get(User.id == 1)
<__main__.User object at 0x25294d0>

>>> User.get_by_id(1)  # 同上。
<__main__.User object at 0x252df10>

>>> User[1]  # 也同上。
<__main__.User object at 0x252dd10>

>>> User.get(User.id == 1).username
u'Charlie'

>>> User.get(User.username == 'Charlie')
<__main__.User object at 0x2529410>

>>> User.get(User.username == 'nobody')
UserDoesNotExist: instance matching query does not exist:
SQL: SELECT t1."id", t1."username" FROM "user" AS t1 WHERE t1."username" = ?
PARAMS: ['nobody']

从源码看(peewee.py),get() 支持同时传多个条件或 Django 风格的过滤关键字;get_by_id 只是 get(primary_key == pk) 的封装(peewee.py)。

7.2 更高级的查询:SelectBase.get

对于更复杂的需求,可以用 SelectBase.get。下面查询名为 charlie 的用户发布的最新推文:

>>> (Tweet
...  .select()
...  .join(User)
...  .where(User.username == 'charlie')
...  .order_by(Tweet.created_date.desc())
...  .get())
<__main__.Tweet object at 0x2623410>

相关 API 一览:

  • Model.get
  • Model.get_by_id
  • Model.get_or_none —— 无匹配时返回 None(实现见 peewee.py)
  • Model.select
  • SelectBase.get
  • SelectBase.first —— 返回结果集第一条,无则返回 None

八、Create-or-Get 模式

Peewee 提供 Model.get_or_create:先尝试检索匹配行,失败则创建新行。

对于"先创建、失败再获取"(create-or-get)的逻辑,通常依赖 unique 约束或主键来防止重复。以注册新用户为例,User 模型对 username 有唯一约束,因此可依靠数据库的完整性保证:

try:
    with db.atomic():
        return User.create(username=username)
except peewee.IntegrityError:
    # `username` 是唯一列,所以这个用户名一定已存在,
    # 此时安全地调用 .get()。
    return User.get(User.username == username)

你可以把这种逻辑封装为自定义 Model 类上的 classmethod。

上面的例子是"先尝试创建,再回退检索"。如果你更倾向于先检索,则用 Model.get_or_create。它与 Django 同名函数实现思路一致,支持 Django 风格的关键字过滤参数,返回一个 2 元组:实例和是否新建的布尔值。

用户注册用 get_or_create 可以这样写:

user, created = User.get_or_create(username=username)

假设有一个 Person 模型,检索时只关心 first name 和 last name,但如果最终需要新建记录,还要写入出生日期和最喜欢的颜色:

person, created = Person.get_or_create(
    first_name=first_name,
    last_name=last_name,
    defaults={'dob': dob, 'favorite_color': 'green'})

传给 get_or_create 的所有关键字参数都会用于 get() 部分,唯独 defaults 字典例外——它只用于填充新建实例的值。

从实现看(peewee.py),get_or_create 会先构造 WHERE 查询尝试 get();失败后开启 atomic() 事务执行 create(),若此时再遇 IntegrityError(并发场景下他人已插入),则回退再查一次。


九、查询多条记录

9.1 迭代、索引与切片

用 Model.select 从表中检索行。Peewee 允许迭代结果,也支持索引和切片:

>>> query = User.select()
>>> [user.username for user in query]
['Charlie', 'Huey', 'Peewee']

>>> query[1]
<__main__.User at 0x7f83e80f5550>

>>> query[1].username
'Huey'

>>> query[:2]
[<__main__.User at 0x7f83e80f53a8>, <__main__.User at 0x7f83e80f5550>]

Select 查询很"聪明":你可以多次迭代、索引、切片,但查询只会执行一次——因为结果被缓存了。

最简单的用法是直接迭代,返回 User 表所有行:

>>> for user in User.select():
...     print(user.username)
...
Charlie
Huey
Peewee

注意:同一查询的后续迭代不会再次访问数据库(结果已缓存)。如果为了降低内存占用而想禁用缓存,迭代时改用 Select.iterator。

9.2 外键与反向引用(backref)

迭代包含外键的模型时,注意访问关联模型值的方式——意外解析外键或迭代反向引用可能引发 N+1 查询问题(详见 docs/peewee/querying.rst 中关于 N+1 的说明)。

创建外键(如 Tweet.user)时,可以用 backref 建立反向引用(User.tweets)。反向引用以 Select 实例形式暴露:

>>> tweet = Tweet.get()
>>> tweet.user  # 访问外键返回关联模型。
<tw.User at 0x7f3ceb017f50>

>>> user = User.get()
>>> user.tweets  # 访问反向引用返回一个查询。
<peewee.ModelSelect at 0x7f73db3bafd0>

user.tweets 可以像任何 Select 一样迭代:

>>> for tweet in user.tweets:
...     print(tweet.message)
...
hello world
this is fun
look at this picture of my food

9.3 行类型:字典、元组与 namedtuple

除了返回模型实例,Select 查询还可以返回字典、元组和 namedtuple。某些场景下用字典处理行数据更方便:

>>> query = User.select().dicts()
>>> for row in query:
...     print(row)

{'id': 1, 'username': 'Charlie'}
{'id': 2, 'username': 'Huey'}
{'id': 3, 'username': 'Peewee'}

更多信息参见 BaseQuery.namedtuples、BaseQuery.tuples、BaseQuery.dicts。

9.4 迭代大型结果集

默认情况下 Peewee 迭代 Select 查询时会缓存所有行,这是为了支持多次迭代和索引切片而不额外发查询。但当你计划迭代海量行时,缓存反而会成为问题。

使用 iterator 方法可降低迭代大结果集时的内存占用——它逐行产出模型而不缓存:

# 假设我们有 1000 万条 stat 对象要导出到 csv 文件。
stats = Stat.select()

# 我们想象中的序列化器类。
serializer = CSVSerializer()

# 遍历所有 stats 并序列化。
for stat in stats.iterator():
    serializer.serialize_object(stat)

对于简单查询,把行转成字典、namedtuple 或元组还能进一步提速。以下方法可以应用在任意 Select 查询上以改变结果行类型:

  • BaseQuery.dicts
  • BaseQuery.namedtuples
  • BaseQuery.tuples

别忘了链式调用 iterator 以同时降低内存。例如:

# 假设我们有 1000 万条 stat 对象要导出到 csv 文件。
stats = Stat.select()

# 我们的序列化器类。
serializer = CSVSerializer()

# 遍历所有 stats(以元组形式返回、不缓存)并序列化。
for stat_tuple in stats.tuples().iterator():
    serializer.serialize_tuple(stat_tuple)

当迭代包含多表列的大量行时,Peewee 会为每一行重建模型图,复杂图结构下该操作可能较慢。例如同时选取推文、推文作者的 username 和 avatar 时,每行要创建两个对象(tweet 和 user)。除了上述行类型外,还有第四种方法 BaseQuery.objects:它把行作为模型实例返回,但不尝试解析模型图:

query = (Tweet
         .select(Tweet, User)  # 同时选择 tweet 和 user 数据。
         .join(User))

# 注意:user 列存放在独立的 User 实例中,可通过 tweet.user 访问:
for tweet in query:
    print(tweet.user.username, tweet.content)

# 使用 ".objects()" 不会创建 tweet.user 对象,而是把所有
# user 属性直接赋给 tweet 实例:
for tweet in query.objects():
    print(tweet.username, tweet.content)

提示:objects() 还接受任意构造函数,会用行元组调用它(见 peewee.py 附近与本文"行元组/字典/namedtuple"一节)。

为追求最大性能,可以直接执行查询并用底层数据库游标迭代结果。Database.execute 接收查询对象,执行后返回 DB-API 2.0 的 Cursor 对象,游标返回原始行元组:

query = Tweet.select(Tweet.content, User.username).join(User)
cursor = database.execute(query)
for (content, username) in cursor:
    print(username, '->', content)

十、过滤记录

10.1 使用 Python 运算符

可以用普通 Python 运算符过滤记录。Peewee 支持种类繁多的查询运算符(完整表格见 docs/peewee/query_operators.rst):

>>> user = User.get(User.username == 'Charlie')
>>> for tweet in Tweet.select().where(Tweet.user == user, Tweet.is_published == True):
...     print(tweet.user.username, '->', tweet.message)
...
Charlie -> hello world
Charlie -> this is fun

>>> for tweet in Tweet.select().where(Tweet.created_date < datetime.datetime(2011, 1, 1)):
...     print(tweet.message, tweet.created_date)
...
Really old tweet 2010-01-01 00:00:00

10.2 跨连接过滤

>>> for tweet in Tweet.select().join(User).where(User.username == 'Charlie'):
...     print(tweet.message)
hello world
this is fun
look at this picture of my food

10.3 复杂条件:位运算符

表达复杂查询时,用括号配合 Python 的位运算 or 和 and 运算符:

>>> Tweet.select().join(User).where(
...     (User.username == 'Charlie') |
...     (User.username == 'Peewee Herman'))

注意:Peewee 使用位运算符(& 和 |)而非逻辑运算符(and 和 or)。原因是 Python 会把逻辑运算的返回值强转为布尔值。这也是为什么 "IN" 查询必须用 .in_() 而不能用 in 运算符。

where 子句里可以放很多有趣的东西,例如:

  • 字段表达式,如 User.username == 'Charlie'
  • 函数表达式,如 fn.Lower(fn.Substr(User.username, 1, 1)) == 'a'
  • 列与列的比较,如 Employee.salary < (Employee.tenure * 1000) + 40000

还可以嵌套查询。例如,找出用户名以 "a" 开头的用户的推文:

# 获取用户名以 "a" 开头的用户。
a_users = User.select().where(fn.Lower(fn.Substr(User.username, 1, 1)) == 'a')

# ".in_()" 方法表示一个 "IN" 查询。
a_user_tweets = Tweet.select().where(Tweet.user.in_(a_users))

10.4 更多查询示例

更多示例参见 docs/peewee/query_examples.rst,该文档演示了如何用 Peewee 实现 PostgreSQL Exercises 网站上的练习。下面是一些常用模式:

获取活跃用户:

User.select().where(User.active == True)

获取 staff 或 superuser:

User.select().where(
    (User.is_staff == True) | (User.is_superuser == True))

获取名为 "charlie" 的用户的推文:

Tweet.select().join(User).where(User.username == 'charlie')

获取 staff 或 superuser 的推文(假设存在外键关系):

Tweet.select().join(User).where(
    (User.is_staff == True) | (User.is_superuser == True))

用子查询获取 staff 或 superuser 的推文:

staff_super = User.select(User.id).where(
    (User.is_staff == True) | (User.is_superuser == True))
Tweet.select().where(Tweet.user.in_(staff_super))

十一、排序记录

用 Query.order_by 让结果有序:

>>> for t in Tweet.select().order_by(Tweet.created_date):
...     print(t.pub_date)
...
2010-01-01 00:00:00
2011-06-07 14:08:48
2011-06-07 14:12:57

>>> for t in Tweet.select().order_by(Tweet.created_date.desc()):
...     print(t.pub_date)
...
2011-06-07 14:12:57
2011-06-07 14:08:48
2010-01-01 00:00:00

还可以用 + 和 - 前缀运算符表达排序方向:

# 下面这些查询是等价的:
Tweet.select().order_by(Tweet.created_date.desc())

Tweet.select().order_by(-Tweet.created_date)  # 注意 "-" 前缀。

# 类似地,可以用 "+" 表示升序;未指定排序时升序也是默认值。
User.select().order_by(+User.username)

也可以跨连接排序。假设希望先按作者用户名、再按创建时间倒序排列推文:

query = (Tweet
         .select()
         .join(User)
         .order_by(User.username, Tweet.created_date.desc()))

等价 SQL:

SELECT t1."id", t1."user_id", t1."message", t1."is_published", t1."created_date"
FROM "tweet" AS t1
INNER JOIN "user" AS t2
  ON t1."user_id" = t2."id"
ORDER BY t2."username", t1."created_date" DESC

按计算值排序时,可以引入所需的 SQL 表达式,也可以引用分配给该值的别名。先看基础查询:获取所有用户名及其推文数,并按推文数从多到少排序:

query = (User
         .select(User.username, fn.COUNT(Tweet.id).alias('num_tweets'))
         .join(Tweet, JOIN.LEFT_OUTER)
         .group_by(User.username))

方式一:复用 select 子句中的 COUNT 表达式,按推文数倒序:

query = (User
         .select(User.username, fn.COUNT(Tweet.id).alias('num_tweets'))
         .join(Tweet, JOIN.LEFT_OUTER)
         .group_by(User.username)
         .order_by(fn.COUNT(Tweet.id).desc()))

方式二:引用 select 子句中计算值的别名。注意不是直接引用别名,而是用 SQL 辅助类包装:

query = (User
         .select(User.username, fn.COUNT(Tweet.id).alias('num_tweets'))
         .join(Tweet, JOIN.LEFT_OUTER)
         .group_by(User.username)
         .order_by(SQL('num_tweets').desc()))

方式三:更"Peewee 风格"的做法——先把表达式存成变量再复用:

ntweets = fn.COUNT(Tweet.id)
query = (User
         .select(User.username, ntweets.alias('num_tweets'))
         .join(Tweet, JOIN.LEFT_OUTER)
         .group_by(User.username)
         .order_by(ntweets.desc())

十二、随机记录

偶尔需要从数据库随机取一条记录。可以通过按 random 或 rand 函数排序实现(取决于所用数据库)。

PostgreSQL 和 SQLite 使用 Random 函数:

# 抽取 5 位幸运儿:
LotteryNumber.select().order_by(fn.Random()).limit(5)

MySQL 使用 Rand:

# 抽取 5 位幸运儿:
LotteryNumber.select().order_by(fn.Rand()).limit(5)

十三、分页记录

Query.paginate 让"取一页记录"变得简单,接收两个参数:page_number 和 items_per_page。

注意:页码从 1 开始,第一页是第 1 页。

>>> for tweet in Tweet.select().order_by(Tweet.id).paginate(2, 10):
...     print(tweet.message)
...
tweet 10
tweet 11
tweet 12
tweet 13
tweet 14
tweet 15
tweet 16
tweet 17
tweet 18
tweet 19

实现上,paginate 会把 1-based 页码换算为 LIMIT/OFFSET(见 peewee.py):

@Node.copy
def paginate(self, page, paginate_by=20):
    if page > 0:
        page -= 1
    self._limit = paginate_by
    self._offset = page * paginate_by

若需要更细粒度控制,可以随时使用 Query.limit 和 Query.offset。


十四、计数记录

任何 select 查询都可以计数:

>>> Tweet.select().count()
100
>>> Tweet.select().where(Tweet.id > 50).count()
50

Peewee 会把查询包进一个外层计数查询,生成类似 SQL:

SELECT COUNT(1) FROM ( ... your query ... );

源码实现见 peewee.py:它去掉排序、以别名 _wrapped 包裹原查询,再对 SELECT 1 的结果执行 COUNT(1) 标量查询。


十五、聚合记录

15.1 分组计数

假设想列出所有用户及其各自的推文数:

query = (User
         .select(User, fn.Count(Tweet.id).alias('count'))
         .join(Tweet, JOIN.LEFT_OUTER)
         .group_by(User))

查询结果返回带全部正常属性的 User 对象,另外多出一个属性 count 表示每个用户的推文数。使用左外连接(LEFT OUTER JOIN)以包含没有推文的用户。

15.2 带 HAVING 的聚合

假设有一个打标签的应用,想找出关联对象数达到某个阈值的标签。以多对多配置为例:

class Photo(Model):
    image = CharField()

class Tag(Model):
    name = CharField()

class PhotoTag(Model):
    photo = ForeignKeyField(Photo)
    tag = ForeignKeyField(Tag)

找出至少关联 5 张照片的标签:

query = (Tag
         .select()
         .join(PhotoTag)
         .join(Photo)
         .group_by(Tag)
         .having(fn.Count(Photo.id) > 5))

等价 SQL:

SELECT t1."id", t1."name"
FROM "tag" AS t1
INNER JOIN "phototag" AS t2 ON t1."id" = t2."tag_id"
INNER JOIN "photo" AS t3 ON t2."photo_id" = t3."id"
GROUP BY t1."id", t1."name"
HAVING Count(t3."id") > 5

如果还想把关联计数取出来存到标签对象上:

query = (Tag
         .select(Tag, fn.Count(Photo.id).alias('count'))
         .join(PhotoTag)
         .join(Photo)
         .group_by(Tag)
         .having(fn.Count(Photo.id) > 5))

十六、检索标量值

调用 Query.scalar 可以检索标量值:

>>> PageView.select(fn.Count(fn.Distinct(PageView.url))).scalar()
100

传入 as_tuple=True 可以一次检索多个标量:

>>> Employee.select(
...     fn.Min(Employee.salary), fn.Max(Employee.salary)
... ).scalar(as_tuple=True)
(30000, 50000)

实现上 scalar() 默认取第一行的第一个值,as_tuple=True 时返回整行(peewee.py)。


十七、窗口函数(Window Functions)

窗口函数是指作用于 SELECT 查询处理过程中滑动数据窗口的聚合函数。它支持:

  1. 对结果集子集执行聚合;
  2. 计算累计和(running total);
  3. 结果排名;
  4. 把某行值与前面(或后面!)行的值进行比较。

Peewee 通过调用 Function.over 并传入分区或排序参数来创建窗口函数(见 peewee.py)。

下面示例统一使用如下模型与样例数据:

class Sample(Model):
    counter = IntegerField()
    value = FloatField()

data = [(1, 10),
        (1, 20),
        (2, 1),
        (2, 3),
        (3, 100)]
Sample.insert_many(data, fields=[Sample.counter, Sample.value]).execute()

样例表现在包含:

idcountervalue
1110.0
2120.0
321.0
423.0
53100.0

17.1 有序窗口(Ordered Windows)

计算 value 的累计和。为了让它是"累计"的,需要按 id 排序:

query = Sample.select(
    Sample.counter,
    Sample.value,
    fn.SUM(Sample.value).over(order_by=[Sample.id]).alias('total'))

for sample in query:
    print(sample.counter, sample.value, sample.total)

# 1    10.    10.
# 1    20.    30.
# 2     1.    31.
# 2     3.    34.
# 3   100    134.

再举一例:计算当前值与上一个值(按 id 排序)的差:

difference = Sample.value - fn.LAG(Sample.value, 1).over(order_by=[Sample.id])
query = Sample.select(
    Sample.counter,
    Sample.value,
    difference.alias('diff'))

for sample in query:
    print(sample.counter, sample.value, sample.diff)

# 1    10.   NULL
# 1    20.    10.  -- (20 - 10)
# 2     1.   -19.  -- (1 - 20)
# 2     3.     2.  -- (3 - 1)
# 3   100     97.  -- (100 - 3)

17.2 分区窗口(Partitioned Windows)

计算每个不同 "counter" 值的平均 value。counter 有 1、2、3 三种可能取值,可以按 counter 分区计算 AVG():

query = Sample.select(
    Sample.counter,
    Sample.value,
    fn.AVG(Sample.value).over(partition_by=[Sample.counter]).alias('cavg'))

for sample in query:
    print(sample.counter, sample.value, sample.cavg)

# 1    10.    15.
# 1    20.    15.
# 2     1.     2.
# 2     3.     2.
# 3   100    100.

同时指定 order_by 与 partition_by 可以在分区内排序。例如在每个 counter 组内按 value 排名:

query = Sample.select(
    Sample.counter,
    Sample.value,
    fn.RANK().over(
        order_by=[Sample.value],
        partition_by=[Sample.counter]).alias('rank'))

for sample in query:
    print(sample.counter, sample.value, sample.rank)

# 1    10.    1
# 1    20.    2
# 2     1.    1
# 2     3.    2
# 3   100     1

17.3 有界窗口(Bounded Windows)

默认情况下,窗口函数以 unbounded preceding(无界前驱)为起点、当前行 为终点进行评估。可以通过在 Function.over 中指定 start 和/或 end 改变窗口边界。Peewee 还在 Window 对象上提供了辅助方法生成合适的边界引用(Window 类定义见 peewee.py 起):

  • Window.CURRENT_ROW —— 引用当前行的属性。
  • Window.preceding —— 指定前几行;省略数字表示所有前驱行。
  • Window.following —— 指定后几行;省略数字表示所有后续行。

下面计算 value 的累计和(按 id 排序),但只看当前行及其前两行的累计:

query = Sample.select(
    Sample.counter,
    Sample.value,
    fn.SUM(Sample.value).over(
        order_by=[Sample.id],
        start=Window.preceding(2),
        end=Window.CURRENT_ROW).alias('rsum'))

for sample in query:
    print(sample.counter, sample.value, sample.rsum)

# 1    10.    10.
# 1    20.    30.  -- (20 + 10)
# 2     1.    31.  -- (1 + 20 + 10)
# 2     3.    24.  -- (3 + 1 + 20)
# 3   100    104.  -- (100 + 3 + 1)

严格来说无需显式写 end=Window.CURRENT,因为那是默认值,这里仅为演示。

再看一个例子:计算累计和的"反向"——所有值的总和按 id 顺序逐步减去当前样本值。做法是计算从当前行到最后一行的和:

query = Sample.select(
    Sample.counter,
    Sample.value,
    fn.SUM(Sample.value).over(
        order_by=[Sample.id],
        start=Window.CURRENT_ROW,
        end=Window.following()).alias('rsum'))

# 1    10.   134.  -- (10 + 20 + 1 + 3 + 100)
# 1    20.   124.  -- (20 + 1 + 3 + 100)
# 2     1.   104.  -- (1 + 3 + 100)
# 2     3.   103.  -- (3 + 100)
# 3   100    100.  -- (100)

17.4 过滤聚合(Filtered Aggregates)

聚合函数还支持 filter 函数(PostgreSQL 和 SQLite 3.25+),会被翻译成 FILTER (WHERE...) 子句。过滤表达式用 Function.filter 方法附加到聚合函数上。

例如计算 value 的累计和(按 id),但排除 counter=2 的样本:

query = Sample.select(
    Sample.counter,
    Sample.value,
    fn.SUM(Sample.value).filter(Sample.counter != 2).over(
        order_by=[Sample.id]).alias('csum'))

for sample in query:
    print(sample.counter, sample.value, sample.csum)

# 1    10.    10.
# 1    20.    30.
# 2     1.    30.
# 2     3.    30.
# 3   100    130.

注意:filter 调用必须先于 over 调用。

17.5 复用窗口定义

如果多个聚合要共用同一个窗口定义,可以创建 Window 对象。Window 接收与 Function.over 相同的参数,可以代替单个参数传给 over()。

这里声明一个按 id 排序的窗口,并用该定义调用多个窗口函数:

win = Window(order_by=[Sample.id])
query = Sample.select(
    Sample.counter,
    Sample.value,
    fn.LEAD(Sample.value).over(win),
    fn.LAG(Sample.value).over(win),
    fn.SUM(Sample.value).over(win)
).window(win)  # 在查询中包含我们的窗口定义。

for row in query.tuples():
    print(row)

# counter  value  lead()  lag()  sum()
# 1          10.     20.   NULL    10.
# 1          20.      1.    10.    30.
# 2           1.      3.    20.    31.
# 2           3.    100.     1.    34.
# 3         100.    NULL     3.   134.

17.6 多个窗口定义

上一个例子演示了声明一个 Window 定义并复用于多个聚合。你可以在查询中包含任意数量的窗口定义,但必须保证每个窗口有唯一别名:

w1 = Window(order_by=[Sample.id]).alias('w1')
w2 = Window(partition_by=[Sample.counter]).alias('w2')
query = Sample.select(
    Sample.counter,
    Sample.value,
    fn.SUM(Sample.value).over(w1).alias('rsum'),  # 累计和。
    fn.AVG(Sample.value).over(w2).alias('cavg')   # 每类均值。
).window(w1, w2)  # 包含我们的窗口定义。

for sample in query:
    print(sample.counter, sample.value, sample.rsum, sample.cavg)

# counter  value   rsum     cavg
# 1          10.     10.     15.
# 1          20.     30.     15.
# 2           1.     31.      2.
# 2           3.     34.      2.
# 3         100     134.    100.

类似地,若多个窗口定义有相似部分,可以扩展已定义的窗口。例如先按 counter 分区,再定义第二个窗口在分区基础上追加排序子句:

w1 = Window(partition_by=[Sample.counter]).alias('w1')

# 通过扩展 w1,这个窗口定义同样按 "counter" 分区。
w2 = Window(extends=w1, order_by=[Sample.value.desc()]).alias('w2')

query = (Sample
         .select(Sample.counter, Sample.value,
                 fn.SUM(Sample.value).over(w1).alias('group_sum'),
                 fn.RANK().over(w2).alias('revrank'))
         .window(w1, w2)
         .order_by(Sample.id))

for sample in query:
    print(sample.counter, sample.value, sample.group_sum, sample.revrank)

# counter  value   group_sum   revrank
# 1        10.     30.         2
# 1        20.     30.         1
# 2        1.      4.          2
# 2        3.      4.          1
# 3        100.    100.        1

17.7 帧类型:RANGE vs ROWS vs GROUPS

帧类型不同,数据库处理有序分组的方式也不同。先额外插入两行 Sample 数据来观察差异:

>>> Sample.create(counter=1, value=20.)
<Sample 6>
>>> Sample.create(counter=2, value=1.)
<Sample 7>

表内现在有:

idcountervalue
1110.0
2120.0
321.0
423.0
53100.0
6120.0
721.0

按 counter 和 value 排序,计算样本的"累计和"来观察帧类型差异。指定帧类型可用:

  • Window.RANGE
  • Window.ROWS
  • Window.GROUPS

RANGE 在存在逻辑重复值时可能产生意外结果:

query = Sample.select(
    Sample.counter,
    Sample.value,
    fn.SUM(Sample.value).over(
        order_by=[Sample.counter, Sample.value],
        frame_type=Window.RANGE).alias('rsum'))

for sample in query.order_by(Sample.counter, Sample.value):
    print(sample.counter, sample.value, sample.rsum)

# counter  value   rsum
# 1          10.     10.
# 1          20.     50.
# 1          20.     50.
# 2           1.     52.
# 2           1.     52.
# 2           3.     55.
# 3         100     155.

新增行后,部分行拥有重复的 category 和 value 值。RANGE 帧类型会把这些重复值一并评估而非分别评估。

更符合直觉的结果用 ROWS 帧类型获得:

query = Sample.select(
    Sample.counter,
    Sample.value,
    fn.SUM(Sample.value).over(
        order_by=[Sample.counter, Sample.value],
        frame_type=Window.ROWS).alias('rsum'))

for sample in query.order_by(Sample.counter, Sample.value):
    print(sample.counter, sample.value, sample.rsum)

# counter  value   rsum
# 1          10.     10.
# 1          20.     30.
# 1          20.     50.
# 2           1.     51.
# 2           1.     52.
# 2           3.     55.
# 3         100     155.

Peewee 选择帧类型的规则:

  • 若用户指定了 frame_type,则使用该帧类型;
  • 若指定了 start 和/或 end 边界,Peewee 默认使用 ROWS;
  • 若用户未指定帧类型或边界,则使用数据库默认值 RANGE。

GROUPS 帧类型根据排序项,按行组来理解窗口范围。用 GROUPS 可以定义覆盖不同行分组的帧。看示例:

query = (Sample
         .select(Sample.counter, Sample.value,
                 fn.SUM(Sample.value).over(
                    order_by=[Sample.counter, Sample.value],
                    frame_type=Window.GROUPS,
                    start=Window.preceding(1)).alias('gsum'))
         .order_by(Sample.counter, Sample.value))

for sample in query:
    print(sample.counter, sample.value, sample.gsum)

#  counter   value    gsum
#  1         10       10
#  1         20       50
#  1         20       50   (10) + (20+0)
#  2         1        42
#  2         1        42   (20+20) + (1+1)
#  2         3        5    (1+1) + 3
#  3         100      103  (3) + 100

可以推断:窗口按排序项 (counter, value) 分组,这里观察的是上一个组到当前组之间的窗口。

关于窗口函数 API 的更多信息,参见 Function.over、Function.filter 与 Window 的文档;窗口函数通用背景知识可参考 PostgreSQL 的窗口函数教程及其官方文档、SQLite 的窗口函数文档。


十八、检索行元组 / 字典 / namedtuple

有时你并不需要创建模型实例的开销,只想迭代行数据。此时使用:

  • BaseQuery.dicts
  • BaseQuery.namedtuples
  • BaseQuery.tuples
  • BaseQuery.objects —— 接受任意构造函数,用行元组调用它。

示例——按 URL 分组统计:

stats = (Stat
         .select(Stat.url, fn.Count(Stat.url))
         .group_by(Stat.url)
         .tuples())

# 迭代一组 2 元组,包含 url 和计数。
for stat_url, stat_count in stats:
    print(stat_url, stat_count)

用 dicts() 把游标行返回为字典:

stats = (Stat
         .select(Stat.url, fn.Count(Stat.url).alias('ct'))
         .group_by(Stat.url)
         .dicts())

# 迭代一组字典,包含 url 和计数。
for stat in stats:
    print(stat['url'], stat['ct'])

十九、RETURNING 子句

PostgresqlDatabase 支持在 UPDATE、INSERT 和 DELETE 查询上使用 RETURNING 子句。指定 RETURNING 后,可以迭代查询所访问的行。

默认情况下,各类查询执行后的返回值为:

  • INSERT —— 新插入行的自增主键值。当未使用自增主键时,Postgres 会返回新行的主键,而 SQLite 和 MySQL 不会;
  • UPDATE —— 被修改的行数;
  • DELETE —— 被删除的行数。

一旦使用 RETURNING 子句,执行查询的返回值就变成可迭代的游标对象。

PostgreSQL 允许通过 RETURNING 返回被插入或被修改行的数据。例如:有一个 Update 会把注册过期的所有用户账户停用,停用后要给每位用户发邮件通知。与其写 SELECT + UPDATE 两条查询,不如用带 RETURNING 的单条 UPDATE:

query = (User
         .update(is_active=False)
         .where(User.registration_expired == True)
         .returning(User))

# 给每个被停用的用户发邮件。
for deactivate_user in query.execute():
    send_deactivation_email(deactivated_user.email)

RETURNING 子句同样可用于 Insert 和 Delete:用于 INSERT 时返回新建行,用于 DELETE 时返回被删除的行。

RETURNING 的唯一限制是:它只能由查询 FROM 子句中出现的表的列组成。要选择某张表的全部列,直接传入 Model 类即可。

再举一例:添加用户并把创建时间设为服务端生成的当前时间戳,然后在一条查询里取回新用户的 ID、Email 和创建时间戳:

query = (User
         .insert(email='foo@bar.com', created=fn.now())
         .returning(User))  # 表示 User 上的所有列。

# 使用 RETURNING 时,execute() 返回一个游标。
cursor = query.execute()

# 获取刚插入的 user 对象并记录数据:
user = cursor[0]
logger.info('Created user %s (id=%s) at %s', user.email, user.id, user.created)

默认游标返回 Model 实例,但你可以指定其他行类型:

data = [{'name': 'charlie'}, {'name': 'huey'}, {'name': 'mickey'}]
query = (User
         .insert_many(data)
         .returning(User.id, User.username)
         .dicts())

for new_user in query.execute():
    print('Added user "%s", id=%s' % (new_user['username'], new_user['id']))

与 Select 查询一样,你可以指定各种结果行类型(见上文"检索行元组/字典/namedtuple")。


二十、公共表表达式(CTE)

Peewee 支持在所有类型查询中包含公共表表达式(CTE)。CTE 的典型用途:

  • 抽出公共子查询;
  • 按 CTE 结果集中派生的列分组或过滤;
  • 编写递归查询。

要把 Select 查询声明为 CTE,使用 SelectQuery.cte 方法,它会把查询包装进 CTE 对象;要让 CTE 成为查询的一部分,使用 Query.with_cte 方法并传入 CTE 对象列表(源码见 peewee.py 与 peewee.py)。

20.1 简单示例

假设有一批由 key 和浮点值组成的数据点:

class Sample(Model):
    key = TextField()
    value = FloatField()

data = (
    ('a', (1.25, 1.5, 1.75)),
    ('b', (2.1, 2.3, 2.5, 2.7, 2.9)),
    ('c', (3.5, 3.5)))

# 填充数据。
for key, values in data:
    Sample.insert_many([(key, value) for value in values],
                       fields=[Sample.key, Sample.value]).execute()

用 CTE 计算:对每个 key,哪些值高于该 key 的平均值:

# 先声明用作 CTE 的查询:计算每个 key 的平均值。
cte = (Sample
       .select(Sample.key, fn.AVG(Sample.value).alias('avg_value'))
       .group_by(Sample.key)
       .cte('key_avgs', columns=('key', 'avg_value')))

# 现在查询 sample 表,用 CTE 找出 value 超过该 key 平均值的行,
# 并计算超出平均值多少。
query = (Sample
         .select(Sample.key, Sample.value)
         .join(cte, on=(Sample.key == cte.c.key))
         .where(Sample.value > cte.c.avg_value)
         .order_by(Sample.value)
         .with_cte(cte))

迭代查询结果,即可看到各组中高于平均值的样本:

>>> for sample in query:
...     print(sample.key, sample.value)

# 'a', 1.75
# 'b', 2.7
# 'b', 2.9

20.2 复杂示例

再看一个更完整的例子:用多个 CTE 找出顶级销售区域中每个产品的销售额汇总。模型如下:

class Order(Model):
    region = TextField()
    amount = FloatField()
    product = TextField()
    quantity = IntegerField()

先给出 SQL 版本(出自 PostgreSQL 官方文档的经典示例):

WITH regional_sales AS (
    SELECT region, SUM(amount) AS total_sales
    FROM orders
    GROUP BY region
  ), top_regions AS (
    SELECT region
    FROM regional_sales
    WHERE total_sales > (SELECT SUM(total_sales) / 10 FROM regional_sales)
  )
SELECT region,
       product,
       SUM(quantity) AS product_units,
       SUM(amount) AS product_sales
FROM orders
WHERE region IN (SELECT region FROM top_regions)
GROUP BY region, product;

用 Peewee 书写等价逻辑:

reg_sales = (Order
             .select(Order.region,
                     fn.SUM(Order.amount).alias('total_sales'))
             .group_by(Order.region)
             .cte('regional_sales'))

top_regions = (reg_sales
               .select(reg_sales.c.region)
               .where(reg_sales.c.total_sales > (
                   reg_sales.select(fn.SUM(reg_sales.c.total_sales) / 10)))
               .cte('top_regions'))

query = (Order
         .select(Order.region,
                 Order.product,
                 fn.SUM(Order.quantity).alias('product_units'),
                 fn.SUM(Order.amount).alias('product_sales'))
         .where(Order.region.in_(top_regions.select(top_regions.c.region)))
         .group_by(Order.region, Order.product)
         .with_cte(reg_sales, top_regions))

注意通过 cte.c.列名 访问 CTE 的列,以及 reg_sales.select(...) 在 CTE 之上继续构造子查询的用法。

20.3 递归 CTE

Peewee 支持递归 CTE,在处理由父外键表示的树形结构时非常有用。例如在线书店的分类层级,希望生成一张表展示所有分类及其绝对深度、从根到该分类的路径。

假设每个分类有指向直接父分类的外键:

class Category(Model):
    name = TextField()
    parent = ForeignKeyField('self', backref='children', null=True)

用递归 CTE 列出所有分类及其深度与父级路径:

# 定义递归 CTE 的基准情形:parent 外键为空的分类。
Base = Category.alias()
level = Value(1).alias('level')
path = Base.name.alias('path')
base_case = (Base
             .select(Base.id, Base.name, Base.parent, level, path)
             .where(Base.parent.is_null())
             .cte('base', recursive=True))

# 定义递归项。
RTerm = Category.alias()
rlevel = (base_case.c.level + 1).alias('level')
rpath = base_case.c.path.concat('->').concat(RTerm.name).alias('path')
recursive = (RTerm
             .select(RTerm.id, RTerm.name, RTerm.parent, rlevel, rpath)
             .join(base_case, on=(RTerm.parent == base_case.c.id)))

# 递归 CTE 由基准情形与递归项 UNION ALL 而成。
cte = base_case.union_all(recursive)

# 现在从 CTE 中查询分类、层级和路径。
query = (cte
         .select_from(cte.c.name, cte.c.level, cte.c.path)
         .order_by(cte.c.path))

# 迭代所有分类,打印名称、绝对层级与从根到分类的路径。
for category in query:
    print(category.name, category.level, category.path)

# 示例输出:
# root, 1, root
# p1, 2, root->p1
# c1-1, 3, root->p1->c1-1
# c1-2, 3, root->p1->c1-2
# p2, 2, root->p2
# c2-1, 3, root->p2->c2-1

要点解析:

  • Category.alias() 为同一张表创建别名以便自连接;
  • base_case 选取根分类(parent IS NULL),并给初始 level(Value(1))和 path(自身名字);
  • 递归项通过 RTerm.parent == base_case.c.id 连接基准,逐层 level + 1、path.concat('->').concat(name);
  • 最终用 cte.select_from(...) 从 CTE 结果中查询。

20.4 数据修改型 CTE

Peewee 还支持数据修改型 CTE。下面用单条查询把数据从一张表移动到归档表:

class Event(Model):
    name = CharField()
    timestamp = DateTimeField()

class Archive(Model):
    name = CharField()
    timestamp = DateTimeField()

# 把 Event 表中 24 小时之前的行移动到 Archive。
cte = (Event
       .delete()
       .where(Event.timestamp < (datetime.now() - timedelta(days=1)))
       .returning(Event)
       .cte('moved_rows'))

# 创建一个简单的 SELECT 从 CTE 获取结果行。
src = Select((cte,), (cte.c.id, cte.c.name, cte.c.timestamp))

# 把 DELETE 返回的数据插入归档表。
res = (Archive
       .insert_from(src, (Archive.id, Archive.name, Archive.timestamp))
       .with_cte(cte)
       .execute())

大致对应如下 SQL:

WITH "moved_rows" AS (
    DELETE FROM "event" WHERE ("timestamp" < XXXX-XX-XXTXX:XX:XX)
    RETURNING "id", "name", "timestamp")
INSERT INTO "archive" ("id", "name", "timestamp")
SELECT "moved_rows"."id", "moved_rows"."name", "moved_rows"."timestamp"
FROM "moved_rows";

更多示例可参考仓库中的测试文件 tests/models.py 与 tests/sql.py。


二十一、相关文档

  • 后端
  • ORM
  • 数据库

【免费下载链接】peewee

a small, expressive orm -- supports postgresql, mysql, sqlite, now with asyncio

项目地址: https://gitcode.com/gh_mirrors/pe/peewee
点击查看 免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值