關聯和聯接

在這份文件中,我們將介紹 Peewee 如何處理模型之間的關聯。

模型定義

我們將使用以下模型定義作為範例

import datetime
from peewee import *


db = SqliteDatabase(':memory:')

class BaseModel(Model):
    class Meta:
        database = db

class User(BaseModel):
    username = TextField()

class Tweet(BaseModel):
    content = TextField()
    timestamp = DateTimeField(default=datetime.datetime.now)
    user = ForeignKeyField(User, backref='tweets')

class Favorite(BaseModel):
    user = ForeignKeyField(User, backref='favorites')
    tweet = ForeignKeyField(Tweet, backref='favorites')

Peewee 使用 ForeignKeyField 來定義模型之間的外鍵關聯。每個外鍵欄位都有一個隱含的反向參照,它會透過提供的 backref 屬性公開為一個預先篩選過的 Select 查詢。

建立測試資料

為了跟上範例的進度,讓我們用一些測試資料來填充這個資料庫

def populate_test_data():
    db.create_tables([User, Tweet, Favorite])

    data = (
        ('huey', ('meow', 'hiss', 'purr')),
        ('mickey', ('woof', 'whine')),
        ('zaizee', ()))
    for username, tweets in data:
        user = User.create(username=username)
        for tweet in tweets:
            Tweet.create(user=user, content=tweet)

    # Populate a few favorites for our users, such that:
    favorite_data = (
        ('huey', ['whine']),
        ('mickey', ['purr']),
        ('zaizee', ['meow', 'purr']))
    for username, favorites in favorite_data:
        user = User.get(User.username == username)
        for content in favorites:
            tweet = Tweet.get(Tweet.content == content)
            Favorite.create(user=user, tweet=tweet)

這給了我們以下內容

使用者

推文

按讚者

huey

zaizee

huey

huey

呼嚕

mickey, zaizee

mickey

mickey

嗚咽

huey

注意

在以下範例中,我們將執行許多查詢。如果您不確定執行了多少個查詢,您可以新增以下程式碼,它會將所有查詢記錄到控制台中

import logging
logger = logging.getLogger('peewee')
logger.addHandler(logging.StreamHandler())
logger.setLevel(logging.DEBUG)

注意

在 SQLite 中,預設情況下不會啟用外鍵。大多數東西(包括 Peewee 外鍵 API)都能正常運作,但 ON DELETE 行為會被忽略,即使您在 ForeignKeyField 中明確指定 on_delete 也是如此。結合預設的 AutoField 行為(其中刪除的記錄 ID 可以重複使用),這可能會導致細微的錯誤。為了避免問題,我建議您在使用 SQLite 時啟用外鍵約束,方法是在您實例化 SqliteDatabase 時設定 pragmas={'foreign_keys': 1}

# Ensure foreign-key constraints are enforced.
db = SqliteDatabase('my_app.db', pragmas={'foreign_keys': 1})

執行簡單聯接

作為學習如何使用 Peewee 執行聯接的練習,讓我們編寫一個查詢,以印出「huey」的所有推文。為此,我們將從 Tweet 模型選取,並聯接到 User 模型,以便我們可以篩選 User.username 欄位

>>> query = Tweet.select().join(User).where(User.username == 'huey')
>>> for tweet in query:
...     print(tweet.content)
...
meow
hiss
purr

注意

我們不必明確指定聯接述詞(「ON」子句),因為 Peewee 從模型中推斷出,當我們從 Tweet 聯接到 User 時,我們聯接的是 Tweet.user 外鍵。

以下程式碼是等效的,但更明確

query = (Tweet
         .select()
         .join(User, on=(Tweet.user == User.id))
         .where(User.username == 'huey'))

如果我們已經有了「huey」的 User 物件的參考,我們可以使用 User.tweets 反向參照來列出 huey 的所有推文

>>> huey = User.get(User.username == 'huey')
>>> for tweet in huey.tweets:
...     print(tweet.content)
...
meow
hiss
purr

仔細看看 huey.tweets,我們可以發現它只是一個簡單的預先篩選過的 SELECT 查詢

>>> huey.tweets
<peewee.ModelSelect at 0x7f0483931fd0>

>>> huey.tweets.sql()
('SELECT "t1"."id", "t1"."content", "t1"."timestamp", "t1"."user_id"
  FROM "tweet" AS "t1" WHERE ("t1"."user_id" = ?)', [1])

聯接多個表格

讓我們再次看看聯接,查詢使用者列表並取得他們撰寫的被按讚的推文數量。這將需要我們聯接兩次:從使用者到推文,以及從推文到喜歡。我們將新增額外要求,即應包含未建立任何推文的使用者,以及推文未被按讚的使用者。用 SQL 表示的查詢將是

SELECT user.username, COUNT(favorite.id)
FROM user
LEFT OUTER JOIN tweet ON tweet.user_id = user.id
LEFT OUTER JOIN favorite ON favorite.tweet_id = tweet.id
GROUP BY user.username

注意

在上述查詢中,兩個聯接都是 LEFT OUTER,因為使用者可能沒有任何推文,或者,如果他們有推文,可能沒有任何推文被按讚。

Peewee 有一個聯接內容的概念,這表示每當我們呼叫 join() 方法時,我們隱含地聯接先前聯接的模型(或者,如果這是第一次呼叫,則聯接我們正在選取的模型)。由於我們直接從使用者聯接到推文,然後從推文聯接到喜歡,因此我們可以簡單地寫

query = (User
         .select(User.username, fn.COUNT(Favorite.id).alias('count'))
         .join(Tweet, JOIN.LEFT_OUTER)  # Joins user -> tweet.
         .join(Favorite, JOIN.LEFT_OUTER)  # Joins tweet -> favorite.
         .group_by(User.username))

迭代結果

>>> for user in query:
...     print(user.username, user.count)
...
huey 3
mickey 1
zaizee 0

對於一個涉及多個聯接和切換聯接內容的更複雜的範例,讓我們找出 Huey 的所有推文以及它們被按讚的次數。為此,我們需要執行兩個聯接,並且我們還將使用聚合函數來計算按讚次數。

以下是我們在 SQL 中編寫此查詢的方式

SELECT tweet.content, COUNT(favorite.id)
FROM tweet
INNER JOIN user ON tweet.user_id = user.id
LEFT OUTER JOIN favorite ON favorite.tweet_id = tweet.id
WHERE user.username = 'huey'
GROUP BY tweet.content;

注意

我們使用從推文到喜歡的 LEFT OUTER 聯接,因為推文可能沒有任何喜歡,但我們仍然希望在結果集中顯示其內容(以及零計數)。

使用 Peewee,產生的 Python 程式碼與我們在 SQL 中編寫的程式碼非常相似

query = (Tweet
         .select(Tweet.content, fn.COUNT(Favorite.id).alias('count'))
         .join(User)  # Join from tweet -> user.
         .switch(Tweet)  # Move "join context" back to tweet.
         .join(Favorite, JOIN.LEFT_OUTER)  # Join from tweet -> favorite.
         .where(User.username == 'huey')
         .group_by(Tweet.content))

請注意呼叫 switch() - 它指示 Peewee 將聯接內容設定回 Tweet。如果我們省略了對 switch 的明確呼叫,Peewee 將使用 User(我們上次聯接的模型)作為聯接內容,並使用 Favorite.user 外鍵建構從 User 到 Favorite 的聯接,這將給我們不正確的結果。

如果我們想省略聯接內容切換,我們可以改用 join_from() 方法。以下查詢與上一個查詢等效

query = (Tweet
         .select(Tweet.content, fn.COUNT(Favorite.id).alias('count'))
         .join_from(Tweet, User)  # Join tweet -> user.
         .join_from(Tweet, Favorite, JOIN.LEFT_OUTER)  # Join tweet -> favorite.
         .where(User.username == 'huey')
         .group_by(Tweet.content))

我們可以迭代上述查詢的結果,以印出推文的內容和按讚次數

>>> for tweet in query:
...     print('%s favorited %d times' % (tweet.content, tweet.count))
...
meow favorited 1 times
hiss favorited 0 times
purr favorited 2 times

從多個來源選取

如果我們想列出資料庫中的所有推文,以及它們作者的使用者名稱,您可能會嘗試編寫這個

>>> for tweet in Tweet.select():
...     print(tweet.user.username, '->', tweet.content)
...
huey -> meow
huey -> hiss
huey -> purr
mickey -> woof
mickey -> whine

上述迴圈有一個很大的問題:它會為每條推文執行額外查詢,以查詢 tweet.user 外鍵。對於我們的小型表格,效能損失並不顯著,但隨著列數的增加,我們發現延遲會增加。

如果您熟悉 SQL,您可能會記得可以從多個表格中選取,讓我們可以在單個查詢中取得推文內容使用者名稱

SELECT tweet.content, user.username
FROM tweet
INNER JOIN user ON tweet.user_id = user.id;

Peewee 使此操作非常容易。實際上,我們只需要稍微修改我們的查詢即可。我們告訴 Peewee 我們希望選取 Tweet.content 以及 User.username 欄位,然後我們包含從推文到使用者的聯接。為了更清楚地說明它正在執行正確的操作,我們可以要求 Peewee 以字典形式傳回列。

>>> for row in Tweet.select(Tweet.content, User.username).join(User).dicts():
...     print(row)
...
{'content': 'meow', 'username': 'huey'}
{'content': 'hiss', 'username': 'huey'}
{'content': 'purr', 'username': 'huey'}
{'content': 'woof', 'username': 'mickey'}
{'content': 'whine', 'username': 'mickey'}

現在我們將取消呼叫 ".dicts()",並將列傳回為 Tweet 物件。請注意,Peewee 將 username 值指派給 tweet.user.username – 而不是 tweet.username!由於從推文到使用者存在外鍵,並且我們選取了兩個模型的欄位,因此 Peewee 將為我們重建模型圖

>>> for tweet in Tweet.select(Tweet.content, User.username).join(User):
...     print(tweet.user.username, '->', tweet.content)
...
huey -> meow
huey -> hiss
huey -> purr
mickey -> woof
mickey -> whine

如果我們願意,我們可以在上述查詢中控制 Peewee 將聯接的 User 實例放置在哪裡,方法是在 join() 方法中指定 attr

>>> query = Tweet.select(Tweet.content, User.username).join(User, attr='author')
>>> for tweet in query:
...     print(tweet.author.username, '->', tweet.content)
...
huey -> meow
huey -> hiss
huey -> purr
mickey -> woof
mickey -> whine

反之,如果我們只是希望我們選取的所有屬性都是 Tweet 實例的屬性,我們可以在查詢結尾新增對 objects() 的呼叫(類似於我們呼叫 dicts() 的方式)

>>> for tweet in query.objects():
...     print(tweet.username, '->', tweet.content)
...
huey -> meow
(etc)

更複雜的範例

作為一個更複雜的範例,在此查詢中,我們將編寫一個單一查詢,以選取所有喜歡的項目,以及建立喜歡的使用者、被喜歡的推文和該推文的作者。

在 SQL 中,我們將寫

SELECT owner.username, tweet.content, author.username AS author
FROM favorite
INNER JOIN user AS owner ON (favorite.user_id = owner.id)
INNER JOIN tweet ON (favorite.tweet_id = tweet.id)
INNER JOIN user AS author ON (tweet.user_id = author.id);

請注意,我們正在選取使用者表格兩次 - 一次是在建立喜歡的使用者的內容中,另一次是作為推文的作者。

使用 Peewee,我們使用 Model.alias() 來為模型類別設定別名,以便可以在單個查詢中引用兩次

Owner = User.alias()
query = (Favorite
         .select(Favorite, Tweet.content, User.username, Owner.username)
         .join(Owner)  # Join favorite -> user (owner of favorite).
         .switch(Favorite)
         .join(Tweet)  # Join favorite -> tweet
         .join(User))   # Join tweet -> user

我們可以迭代結果並以以下方式存取聯接的值。請注意 Peewee 如何解析我們選取的各種模型的欄位並重建模型圖

>>> for fav in query:
...     print(fav.user.username, 'liked', fav.tweet.content, 'by', fav.tweet.user.username)
...
huey liked whine by mickey
mickey liked purr by huey
zaizee liked meow by huey
zaizee liked purr by huey

子查詢

Peewee 允許您聯接任何類似表格的物件,包括子查詢或通用表格表達式 (CTE)。為了示範聯接子查詢,讓我們查詢所有使用者及其最新的推文。

這是 SQL

SELECT tweet.*, user.*
FROM tweet
INNER JOIN (
    SELECT latest.user_id, MAX(latest.timestamp) AS max_ts
    FROM tweet AS latest
    GROUP BY latest.user_id) AS latest_query
ON ((tweet.user_id = latest_query.user_id) AND (tweet.timestamp = latest_query.max_ts))
INNER JOIN user ON (tweet.user_id = user.id)

我們將透過建立一個子查詢來執行此操作,該子查詢會選取每個使用者及其最新推文的時間戳記。然後,我們可以在外部查詢中查詢推文表格,並聯接來自子查詢的使用者和時間戳記組合。

# Define our subquery first. We'll use an alias of the Tweet model, since
# we will be querying from the Tweet model directly in the outer query.
Latest = Tweet.alias()
latest_query = (Latest
                .select(Latest.user, fn.MAX(Latest.timestamp).alias('max_ts'))
                .group_by(Latest.user)
                .alias('latest_query'))

# Our join predicate will ensure that we match tweets based on their
# timestamp *and* user_id.
predicate = ((Tweet.user == latest_query.c.user_id) &
             (Tweet.timestamp == latest_query.c.max_ts))

# We put it all together, querying from tweet and joining on the subquery
# using the above predicate.
query = (Tweet
         .select(Tweet, User)  # Select all columns from tweet and user.
         .join(latest_query, on=predicate)  # Join tweet -> subquery.
         .join_from(Tweet, User))  # Join from tweet -> user.

迭代查詢,我們可以看見每個使用者及其最新的推文。

>>> for tweet in query:
...     print(tweet.user.username, '->', tweet.content)
...
huey -> purr
mickey -> whine

您可能之前沒有看過幾個我們用來在此區段中建立查詢的程式碼

  • 我們使用 join_from() 來明確指定聯接內容。我們撰寫了 .join_from(Tweet, User),這等同於 .switch(Tweet).join(User)

  • 我們使用神奇的 .c 屬性來引用子查詢中的欄位,例如 latest_query.c.max_ts.c 屬性用於動態建立欄位參考。

  • 我們沒有將個別欄位傳遞給 Tweet.select(),而是傳遞了 TweetUser 模型。這是選取給定模型上所有欄位的簡寫。

通用表格表達式

在前一節中,我們加入了子查詢,但我們也可以使用通用表格表達式 (CTE)。我們將重複之前的查詢,列出使用者及其最新的推文,但這次我們將使用 CTE 來完成。

這是 SQL

WITH latest AS (
    SELECT user_id, MAX(timestamp) AS max_ts
    FROM tweet
    GROUP BY user_id)
SELECT tweet.*, user.*
FROM tweet
INNER JOIN latest
    ON ((latest.user_id = tweet.user_id) AND (latest.max_ts = tweet.timestamp))
INNER JOIN user
    ON (tweet.user_id = user.id)

這個範例看起來與先前使用子查詢的範例非常相似。

# Define our CTE first. We'll use an alias of the Tweet model, since
# we will be querying from the Tweet model directly in the main query.
Latest = Tweet.alias()
cte = (Latest
       .select(Latest.user, fn.MAX(Latest.timestamp).alias('max_ts'))
       .group_by(Latest.user)
       .cte('latest'))

# Our join predicate will ensure that we match tweets based on their
# timestamp *and* user_id.
predicate = ((Tweet.user == cte.c.user_id) &
             (Tweet.timestamp == cte.c.max_ts))

# We put it all together, querying from tweet and joining on the CTE
# using the above predicate.
query = (Tweet
         .select(Tweet, User)  # Select all columns from tweet and user.
         .join(cte, on=predicate)  # Join tweet -> CTE.
         .join_from(Tweet, User)  # Join from tweet -> user.
         .with_cte(cte))

我們可以迭代結果集,其中包含每個使用者的最新推文。

>>> for tweet in query:
...     print(tweet.user.username, '->', tweet.content)
...
huey -> purr
mickey -> whine

注意

有關使用 CTE 的更多資訊,包括有關撰寫遞迴 CTE 的資訊,請參閱「查詢」文件中「通用表格表達式」章節。

多個指向同一個模型的外鍵

當有多個指向同一個模型的外鍵時,明確指定您要加入的欄位是一個好習慣。

回顧範例應用程式的模型,考慮使用 *Relationship* 模型來表示一個使用者追蹤另一個使用者的情況。以下是模型的定義:

class Relationship(BaseModel):
    from_user = ForeignKeyField(User, backref='relationships')
    to_user = ForeignKeyField(User, backref='related_to')

    class Meta:
        indexes = (
            # Specify a unique multi-column index on from/to-user.
            (('from_user', 'to_user'), True),
        )

由於有兩個指向 *User* 的外鍵,我們應該始終指定我們在加入時使用的欄位。

例如,若要判斷我正在追蹤哪些使用者,我會寫:

(User
 .select()
 .join(Relationship, on=Relationship.to_user)
 .where(Relationship.from_user == charlie))

另一方面,如果我想要判斷哪些使用者正在追蹤我,我會改為加入 *from_user* 欄位,並篩選關係的 *to_user*:

(User
 .select()
 .join(Relationship, on=Relationship.from_user)
 .where(Relationship.to_user == charlie))

在任意欄位上加入

如果兩個表格之間不存在外鍵,您仍然可以執行加入,但您必須手動指定加入述詞。

在以下範例中,*User* 和 *ActivityLog* 之間沒有明確的外鍵,但 *ActivityLog.object_id* 欄位和 *User.id* 之間存在隱含的關係。我們將使用 Expression 加入,而不是加入特定的 Field

user_log = (User
            .select(User, ActivityLog)
            .join(ActivityLog, on=(User.id == ActivityLog.object_id), attr='log')
            .where(
                (ActivityLog.activity_type == 'user_activity') &
                (User.username == 'charlie')))

for user in user_log:
    print(user.username, user.log.description)

#### Print something like ####
charlie logged in
charlie posted a tweet
charlie retweeted
charlie posted a tweet
charlie logged out

注意

回想一下,我們可以透過在 join() 方法中指定 attr 參數來控制 Peewee 將加入的實例指派到的屬性。在先前的範例中,我們使用了以下的 *join*:

join(ActivityLog, on=(User.id == ActivityLog.object_id), attr='log')

然後,在迭代查詢時,我們可以無需額外的查詢即可直接存取已加入的 *ActivityLog*:

for user in user_log:
    print(user.username, user.log.description)

自我加入

Peewee 支援建構包含自我加入的查詢。

使用模型別名

若要在同一個模型(表格)上加入兩次,則必須建立模型別名來表示查詢中表格的第二個實例。請考慮下列模型:

class Category(Model):
    name = CharField()
    parent = ForeignKeyField('self', backref='children')

如果我們想要查詢所有父類別為 *Electronics* 的類別,該怎麼辦?其中一種方法是執行自我加入:

Parent = Category.alias()
query = (Category
         .select()
         .join(Parent, on=(Category.parent == Parent.id))
         .where(Parent.name == 'Electronics'))

當執行使用 ModelAlias 的加入時,必須使用 on 關鍵字引數來指定加入條件。在此案例中,我們正在將類別與其父類別加入。

使用子查詢

另一種較不常見的方法涉及使用子查詢。以下是使用子查詢建構查詢以取得所有父類別為 *Electronics* 的類別的另一種方法:

Parent = Category.alias()
join_query = Parent.select().where(Parent.name == 'Electronics')

# Subqueries used as JOINs need to have an alias.
join_query = join_query.alias('jq')

query = (Category
         .select()
         .join(join_query, on=(Category.parent == join_query.c.id)))

這將產生下列 SQL 查詢:

SELECT t1."id", t1."name", t1."parent_id"
FROM "category" AS t1
INNER JOIN (
  SELECT t2."id"
  FROM "category" AS t2
  WHERE (t2."name" = ?)) AS jq ON (t1."parent_id" = "jq"."id")

若要從子查詢存取 id 值,我們使用 .c 神奇的查詢,這將產生適當的 SQL 表達式:

Category.parent == join_query.c.id
# Becomes: (t1."parent_id" = "jq"."id")

實作多對多

Peewee 提供一個欄位來表示多對多關係,就像 Django 一樣。此功能是應使用者要求而新增的,但我強烈建議不要使用它,因為它將欄位的概念與連接表格和隱藏的加入混淆了。它只是一個提供方便存取器的糟糕方法。

因此,若要使用 peewee **正確地**實作多對多,您將自行建立中介表格並透過它進行查詢:

class Student(Model):
    name = CharField()

class Course(Model):
    name = CharField()

class StudentCourse(Model):
    student = ForeignKeyField(Student)
    course = ForeignKeyField(Course)

若要查詢,假設我們想要尋找已註冊數學課程的學生:

query = (Student
         .select()
         .join(StudentCourse)
         .join(Course)
         .where(Course.name == 'math'))
for student in query:
    print(student.name)

若要查詢給定學生註冊了哪些課程:

courses = (Course
           .select()
           .join(StudentCourse)
           .join(Student)
           .where(Student.name == 'da vinci'))

for course in courses:
    print(course.name)

若要有效率地迭代多對多關係,例如,列出所有學生及其各自的課程,我們將查詢 *through* 模型 StudentCourse 並 *預先計算* Student 和 Course:

query = (StudentCourse
         .select(StudentCourse, Student, Course)
         .join(Course)
         .switch(StudentCourse)
         .join(Student)
         .order_by(Student.name))

若要列印學生及其課程的清單,您可以執行下列動作:

for student_course in query:
    print(student_course.student.name, '->', student_course.course.name)

由於我們在查詢的 *select* 子句中選取了 StudentCourse 的所有欄位,因此這些外鍵遍歷是「免費的」,而且我們只用一個查詢完成了整個迭代。

ManyToManyField

ManyToManyField 提供多對多欄位上的*類似欄位的* API。對於除了最簡單的多對多情況之外的所有情況,您最好使用標準的 peewee API。但是,如果您的模型非常簡單,而且您的查詢需求不是很複雜,則 ManyToManyField 可能會有效。

使用 ManyToManyField 建模學生和課程:

from peewee import *

db = SqliteDatabase('school.db')

class BaseModel(Model):
    class Meta:
        database = db

class Student(BaseModel):
    name = CharField()

class Course(BaseModel):
    name = CharField()
    students = ManyToManyField(Student, backref='courses')

StudentCourse = Course.students.get_through_model()

db.create_tables([
    Student,
    Course,
    StudentCourse])

# Get all classes that "huey" is enrolled in:
huey = Student.get(Student.name == 'Huey')
for course in huey.courses.order_by(Course.name):
    print(course.name)

# Get all students in "English 101":
engl_101 = Course.get(Course.name == 'English 101')
for student in engl_101.students:
    print(student.name)

# When adding objects to a many-to-many relationship, we can pass
# in either a single model instance, a list of models, or even a
# query of models:
huey.courses.add(Course.select().where(Course.name.contains('English')))

engl_101.students.add(Student.get(Student.name == 'Mickey'))
engl_101.students.add([
    Student.get(Student.name == 'Charlie'),
    Student.get(Student.name == 'Zaizee')])

# The same rules apply for removing items from a many-to-many:
huey.courses.remove(Course.select().where(Course.name.startswith('CS')))

engl_101.students.remove(huey)

# Calling .clear() will remove all associated objects:
cs_150.students.clear()

注意

在新增多對多關係之前,需要先儲存正在參考的物件。為了在多對多 through 表格中建立關係,Peewee 需要知道正在參考的模型的主鍵。

警告

**強烈建議**您不要嘗試子類化包含 ManyToManyField 實例的模型。

ManyToManyField 儘管名稱如此,但並不是通常意義上的欄位。多對多欄位不是表格上的欄位,而是涵蓋了幕後實際上存在一個包含兩個外鍵指標(*through 表格*)的單獨表格的事實。

因此,當建立繼承多對多欄位的子類別時,實際需要繼承的是 *through 表格*。由於存在潛在的細微錯誤,Peewee 不會嘗試自動子類化 through 模型並修改其外鍵指標。因此,多對多欄位通常無法與繼承一起使用。

如需更多範例,請參閱:

避免 N+1 問題

「*N+1 問題*」是指應用程式執行查詢的情況,然後針對結果集的每一列,應用程式至少執行一個其他查詢(另一種將此概念化的方法是作為巢狀迴圈)。在許多情況下,可以透過使用 SQL 加入或子查詢來避免這些 *n* 個查詢。資料庫本身可能會執行巢狀迴圈,但它通常比在您的應用程式程式碼中執行 *n* 個查詢更有效率,這涉及與資料庫通訊的延遲,並且可能不會利用資料庫在加入或執行子查詢時使用的索引或其他最佳化。

Peewee 提供了多個 API 來減輕 *N+1* 查詢行為。回顧本文檔中使用的模型 *User* 和 *Tweet*,此章節將嘗試概述一些常見的 *N+1* 情況,以及 peewee 如何協助您避免這些情況。

注意

在某些情況下,N+1 查詢不會導致顯著或可測量的效能影響。這完全取決於您正在查詢的資料、您正在使用的資料庫,以及執行查詢和擷取結果所涉及的延遲。與進行最佳化時一樣,請在變更前後進行效能分析,以確保變更能夠達成您的預期。

列出最近的推文

Twitter 時間軸會顯示多個使用者的推文清單。除了推文的內容之外,還會顯示推文作者的使用者名稱。此處的 N+1 情況將會是:

  1. 擷取 10 則最近的推文。

  2. 針對每則推文,選取作者(10 個查詢)。

透過選取兩個表格並使用 *join*,peewee 讓您可以使用單一查詢完成此操作:

query = (Tweet
         .select(Tweet, User)  # Note that we are selecting both models.
         .join(User)  # Use an INNER join because every tweet has an author.
         .order_by(Tweet.id.desc())  # Get the most recent tweets.
         .limit(10))

for tweet in query:
    print(tweet.user.username, '-', tweet.message)

如果沒有加入,存取 tweet.user.username 會觸發查詢來解析外鍵 tweet.user 並擷取相關聯的使用者。但是由於我們已選取並加入 User,peewee 將會自動為我們解析外鍵。

注意

從多個來源選取」中更詳細地討論了此技術。

列出使用者及其所有推文

假設您想要建立一個頁面,顯示多個使用者及其所有推文。N+1 情況將會是:

  1. 擷取一些使用者。

  2. 針對每個使用者,擷取其推文。

這種情況與先前的範例類似,但有一個重要的差異:當我們選取推文時,它們只有一個相關聯的使用者,因此我們可以​​直接指派外鍵。但是,反之則不然,因為一個使用者可能會有任意數量的推文(或根本沒有)。

Peewee 提供了一種避免在這種情況下使用 *O(n)* 查詢的方法。首先擷取使用者,然後擷取與這些使用者相關聯的所有推文。一旦 peewee 擁有大量的推文清單,它就會指派它們,將它們與適當的使用者進行比對。此方法通常更快,但會對選取的每個表格進行一個查詢。

使用 prefetch

peewee 支援使用子查詢預先擷取相關資料。此方法需要使用特殊的 API,prefetch()。顧名思義,預先擷取會使用子查詢,主動載入給定使用者的適當推文。這表示我們將執行 *O(k)* 個查詢(針對 *k* 個表格),而不是執行 *n* 個查詢(針對 *n* 列)。

以下範例說明我們如何擷取多個使用者以及他們在過去一週內建立的任何推文:

week_ago = datetime.date.today() - datetime.timedelta(days=7)
users = User.select()
tweets = (Tweet
          .select()
          .where(Tweet.timestamp >= week_ago))

# This will perform two queries.
users_with_tweets = prefetch(users, tweets)

for user in users_with_tweets:
    print(user.username)
    for tweet in user.tweets:
        print('  ', tweet.message)

注意

請注意,無論是 User 查詢還是 Tweet 查詢,都沒有包含 JOIN 子句。當使用 prefetch() 時,您不需要指定 JOIN。

prefetch() 可用於查詢任意數量的表格。請查看 API 文件以獲取更多範例。

使用 prefetch() 時需要考慮的一些事項:

  • 被預先載入的模型之間必須存在外鍵。

  • LIMIT 在最外層查詢中的運作方式如您所預期,但如果嘗試限制子選擇的大小,可能難以正確實作。* 當不支援 LIMIT 時,可以使用參數 prefetch_type

    使用預設查詢構造時 (例如使用 MySQL)。