SQLAlchemy 提供了 ORM 的便利性,但某些資料函式庫操作,例如 PostgreSQL 的右外連線,需要一些技巧才能實作。理解 SQLAlchemy 如何處理外連線,並配合資料函式庫特性調整查詢策略,才能有效率地擷取所需資料。本文除了探討 PostgreSQL 外連線的 SQLAlchemy 實作技巧外,也涵蓋了多語言部落格文章的資料函式庫模型設計,包含語言模型的建立、自參考關聯的應用,以及如何使用 Alembic 進行資料函式庫遷移。此外,文章也提供了多語言文章匯入指令碼的實務解析,以及如何使用 SQLAlchemy 進行多語言文章的統計查詢,包含各語言文章數量、原文與譯文分開統計、以及原文翻譯數量統計等實務案例。

PostgreSQL 外連線(Outer Join)與 SQLAlchemy 的應用

PostgreSQL 支援所有型別的外連線(outer join),但 SQLAlchemy 只實作了完全外連線(full outer join)和左外連線(left outer join)。當需要使用右外連線(right outer join)時,必須交換表格順序以使用左外連線。

將內連線轉換為完全外連線

在前面的查詢中,將第一個連線更改為完全外連線將確保檢索到所有部落格文章,而不僅僅是那些可以與產品匹配的文章。然後,下一個與 BlogView 的連線不會丟棄任何頁面瀏覽量。將預設的內連線轉換為完全外連線的唯一更改是在 join() 子句中新增 full=True 引數:

q = (select(Product.name, page_views)
     .join(Product.blog_articles, full=True)
     .join(BlogArticle.views)
     .where(BlogView.timestamp.between(
         datetime(2022, 11, 1), datetime(2022, 12, 1)))
     .group_by(Product)
     .order_by(page_views.desc()))

內容解密:

  1. .join(Product.blog_articles, full=True):使用完全外連線檢索所有產品和部落格文章。
  2. .join(BlogArticle.views):預設為內連線,將部落格文章與其瀏覽量進行匹配。
  3. .where(BlogView.timestamp.between(...)):篩選特定時間範圍內的頁面瀏覽量。

執行此查詢後,結果將包括產品名稱和頁面瀏覽量,其中包含一個 None 產品名稱的條目,對應於未與任何產品相關聯的部落格文章的頁面瀏覽量。

使用右外連線的替代方案

由於 SQLAlchemy 不直接支援右外連線,可以透過反轉連線方向並使用左外連線來實作相同的效果。這可以透過在 join() 子句中新增 isouter=True 引數來完成:

q = (select(Product.name, page_views)
     .join(BlogArticle.product, isouter=True)
     .join(BlogArticle.views)
     .where(BlogView.timestamp.between(
         datetime(2022, 11, 1), datetime(2022, 12, 1)))
     .group_by(Product)
     .order_by(page_views.desc()))

內容解密:

  1. .join(BlogArticle.product, isouter=True):使用左外連線檢索所有部落格文章及其相關產品,如果沒有相關產品,則產品名稱為 None
  2. .join(BlogArticle.views):將部落格文章與其瀏覽量進行匹配。
  3. .where(BlogView.timestamp.between(...)):篩選特定時間範圍內的頁面瀏覽量。

為什麼完全外連線和右外連線查詢傳回相同的結果?

乍一看,完全外連線應該傳回更多資料,因為它應該包括所有產品和所有部落格文章,無論它們是否匹配。然而,在這個查詢中,第二個連線是內連線,它將產品和部落格文章的配對與 BlogView 記錄進行匹配,這樣,所有 (Product, None) 對都被丟棄,因為 None 無法與任何 BlogView 記錄匹配。

改進查詢以保留所有產品

為了保留所有產品,包括那些沒有部落格文章或沒有頁面瀏覽量的產品,第二個連線也需要升級為完全外連線。同時,where() 子句需要更新以允許 BlogView.timestampNone 的情況:

from sqlalchemy import or_

q = (select(Product.name, page_views)
     .join(Product.blog_articles, full=True)
     .join(BlogArticle.views, full=True)
     .where(or_(
         BlogView.timestamp == None,
         BlogView.timestamp.between(
             datetime(2022, 11, 1), datetime(2022, 12, 1))))
     .group_by(Product)
     .order_by(page_views.desc(), Product.name))

內容解密:

  1. .join(Product.blog_articles, full=True).join(BlogArticle.views, full=True):使用完全外連線檢索所有相關資料。
  2. .where(or_(...)):篩選特定時間範圍內的頁面瀏覽量,或允許 None 值。

在不支援完全外連線的資料函式庫中的解決方案

在不支援完全外連線的資料函式庫中,可以透過執行兩個查詢來模擬完全外連線的效果:一個用於檢索具有頁面瀏覽量的產品,另一個用於檢索沒有頁面瀏覽量的產品。

q2 = (select(Product.name, page_views)
      .join(Product.blog_articles, isouter=True)
      .join(BlogArticle.views, isouter=True)
      .where(or_(
          BlogView.timestamp == None,
          BlogView.timestamp.between(
              datetime(2022, 11, 1), datetime(2022, 12, 1))))
      .group_by(Product)
      .having(page_views == 0)
      .order_by(Product.name))

內容解密:

  1. .join(Product.blog_articles, isouter=True).join(BlogArticle.views, isouter=True):使用左外連線檢索所有相關資料。
  2. .where(or_(...)):篩選特定時間範圍內的頁面瀏覽量,或允許 None 值。
  3. .having(page_views == 0):僅檢索頁面瀏覽量為零的產品。

多語言部落格文章的管理與分析

在 RetroFun 的網站分析專案中,第三階段的主要目標是擴充資料函式庫以追蹤每篇文章的語言,並識別哪些文章是其他文章的翻譯版本。這一階段的變更將使資料函式庫能夠支援多語言內容,並提供更多有價值的報表。

語言模型的建立與關聯

首先,需要新增一個 Language 模型,並在 BlogArticle 模型中建立一個一對多的關聯。這使得每種語言可以對應多篇文章。

程式碼實作:新增語言模型與關聯

class BlogArticle(Model):
    # ...
    language_id: Mapped[Optional[int]] = mapped_column(
        ForeignKey('languages.id'), index=True)
    # ...
    language: Mapped[Optional['Language']] = relationship(
        back_populates='blog_articles')
    # ...

class Language(Model):
    __tablename__ = 'languages'
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(32), index=True, unique=True)
    blog_articles: WriteOnlyMapped['BlogArticle'] = relationship(
        back_populates='language')

    def __repr__(self):
        return f'Language({self.id}, "{self.name}")'

內容解密:

  1. Language 模型:定義了一個新的資料表 languages,包含 idname 欄位,分別代表語言的唯一識別碼和語言名稱。
  2. BlogArticle 中的 language_id:新增了一個外部索引鍵欄位 language_id,與 languages 表的 id 關聯,用於標識每篇文章的語言。
  3. language 關聯屬性:透過 SQLAlchemy 的 relationship,在 BlogArticle 中建立了與 Language 的關聯,使得每篇文章可以對應到一種語言。

自參考關聯:處理文章翻譯

為了追蹤文章之間的翻譯關係,需要在 BlogArticle 模型中建立一個自參考關聯。這是因為一篇文章可以有多個翻譯版本,而每個翻譯版本本身也是一篇文章。

程式碼實作:自參考關聯

class BlogArticle(Model):
    # ...
    translation_of_id: Mapped[Optional[int]] = mapped_column(
        ForeignKey('blog_articles.id'), index=True)
    # ...
    translation_of: Mapped[Optional['BlogArticle']] = relationship(
        remote_side=id, back_populates='translations')
    translations: Mapped[list['BlogArticle']] = relationship(
        back_populates='translation_of')
    # ...

內容解密:

  1. translation_of_id 外部索引鍵:新增了一個外部索引鍵欄位 translation_of_id,它參考了 blog_articles 表自身的 id 欄位,用於標識一篇文章的原始版本。
  2. translation_oftranslations 關聯屬性
    • translation_of:表示某篇文章是另一個文章的翻譯版本,透過 remote_side=id 引數,SQLAlchemy 能夠正確理解這層關係。
    • translations:表示某篇文章擁有的所有翻譯版本。
  3. 自參考關聯的組態:由於這是一個自參考關聯,需要小心組態 remote_side 引數,以消除 SQLAlchemy 在解析關係時的歧義。

資料函式庫遷移

完成模型變更後,需要透過 Alembic 生成並執行資料函式庫遷移指令碼,以更新資料函式庫結構。

(venv) $ alembic revision --autogenerate -m "multi-language support"
(venv) $ alembic upgrade head

內容解密:

  1. alembic revision --autogenerate:自動生成一個新的資料函式庫遷移指令碼,根據模型變更產生相應的 SQL 陳述式。
  2. alembic upgrade head:將生成的遷移指令碼應用到資料函式庫,完成資料函式庫結構的更新。

多語言支援匯入與查詢實務解析

語言與翻譯關係匯入指令碼解析

為實作多語言支援,專案採用了import_languages.py指令碼來匯入語言及翻譯關係。該指令碼主要處理articles.csv檔案中的languagetranslation_of欄位。

程式碼實作重點

import csv
from sqlalchemy import select
from db import Session
from models import BlogArticle, Language

def main():
    with Session() as session:
        with session.begin():
            # 快取機制提升查詢效率
            all_articles = {}
            all_languages = {}
            
            with open('articles.csv') as f:
                reader = csv.DictReader(f)
                for row in reader:
                    # 取得或建立文章物件
                    article = all_articles.get(row['title'])
                    if article is None:
                        article = session.scalar(select(BlogArticle).where(
                            BlogArticle.title == row['title']))
                        all_articles[article.title] = article
                    
                    # 處理語言資訊
                    language = all_languages.get(row['language'])
                    if language is None:
                        language = session.scalar(select(Language).where(
                            Language.name == row['language']))
                        if language is None:
                            language = Language(name=row['language'])
                            session.add(language)
                        all_languages[language.name] = language
                    article.language = language
                    
                    # 處理翻譯關係
                    if row['translation_of']:
                        translation_of = all_articles.get(row['translation_of'])
                        if translation_of is None:
                            translation_of = session.scalar(select(
                                BlogArticle).where(BlogArticle.title == 
                                row['translation_of']))
                            all_articles[article.title] = article
                        article.translation_of = translation_of

if __name__ == '__main__':
    main()

#### 內容解密:

  1. 快取機制的應用:使用all_articlesall_languages字典來快取已載入的文章和語言物件,顯著減少資料函式庫查詢次數。
  2. 語言處理邏輯:首先檢查語言是否已存在,若不存在則建立新的Language例項並加入資料函式庫。
  3. 翻譯關係建立:透過translation_of欄位建立原文與譯文之間的關聯,若文章為譯文則指定其原文。
  4. 資料函式庫操作最佳化:使用with session.begin()確保資料函式庫操作的原子性。

語言查詢實務

在完成語言和翻譯關係的匯入後,可以透過SQLAlchemy進行各種查詢操作。

各語言文章數量統計

q = (select(Language, func.count(BlogArticle.id))
     .join(Language.blog_articles)
     .group_by(Language)
     .order_by(Language.name))
session.execute(q).all()

#### 內容解密:

  1. 查詢邏輯:透過LanguageBlogArticle的關聯進行分組統計。
  2. 排序方式:按語言名稱進行排序。
  3. 統計結果:取得各語言的文章總數。

原文與譯文分開統計

# 原文統計
q = (select(Language, func.count(BlogArticle.id))
     .join(Language.blog_articles)
     .where(BlogArticle.translation_of == None)
     .group_by(Language)
     .order_by(Language.name))

# 譯文統計
q = (select(Language, func.count(BlogArticle.id))
     .join(Language.blog_articles)
     .where(BlogArticle.translation_of != None)
     .group_by(Language)
     .order_by(Language.name))

#### 內容解密:

  1. 篩選條件:使用translation_of欄位判斷文章是否為原文或譯文。
  2. 特別注意:SQLAlchemy中與None的比較必須使用==!=運算元。
  3. 查詢結果:分別取得各語言的原文和譯文數量。

原文翻譯數量統計

TranslatedBlogArticle = aliased(BlogArticle)
article_count = func.count(TranslatedBlogArticle.id).label(None)

q = (select(BlogArticle, article_count)
     .join(TranslatedBlogArticle.translation_of)
     .group_by(BlogArticle)
     .order_by(article_count.desc(), BlogArticle.title))

#### 內容解密:

  1. 別名機制:使用aliased函式為BlogArticle建立別名,以解決自關聯查詢的歧義性。
  2. 統計邏輯:透過分組計數取得每篇原文的翻譯數量。
  3. 排序方式:按翻譯數量降序排列,若數量相同則按標題排序。