sqlite-utils 4.0, now with database schema migrations

Simon Willison

sqlite-utils 4.0,現在支援資料庫結構遷移

原文由 Simon Willison 發布,訂閱此部落格

今天早上我發布了 sqlite-utils 4.0,這是該專案的第 124 個版本,也是自 2020 年 11 月的 3.0 以來首次重大版本更新。除了一些雖小但影響重大的破壞性變更(詳見這份升級指南)之外,這個版本還引入了三項主要新功能:資料庫遷移巢狀交易(透過全新的 db.atomic() 方法),以及對複合外鍵的支援。

使用 sqlite-utils 進行資料庫結構遷移

結構遷移定義了一系列要對 SQLite 資料庫進行的變更,並提供一套機制來追蹤哪些遷移已經套用,以及自動套用所有尚未執行的遷移。

遷移是在 Python 檔案中使用 sqlite-utils Python 函式庫來定義的,該函式庫提供了一個強大的 table.transform() 方法,帶來了增強的 alter table 功能,這些功能是 SQLite 的 ALTER TABLE 語句本身不支援的。

table.transform() 實作了 SQLite 官方文件所推薦的模式——先用新的結構建立一個暫時的資料表,把資料複製過去,然後刪除舊資料表,再將暫時資料表重新命名取代之。)

以下是一個遷移檔案的範例,它會先建立一個名為 creatures 的資料表,第二步再為它新增一個欄位,第三步則變更其中兩個欄位的型別:

from sqlite_utils import Migrations

migrations = Migrations("creatures")

@migrations()
def create_table(db):
    db["creatures"].create(
        {"id": int, "name": str, "species": str},
        pk="id",
    )

@migrations()
def add_weight(db):
    db["creatures"].add_column("weight", float)

@migrations()
def change_column_types(db):
    db["creatures"].transform(types={"species": int, "weight": str})

將上述內容儲存為 migrations.py,然後像這樣在一個全新的資料庫上執行:

uvx sqlite-utils migrate data.db migrations.py

接著如果你查看該資料庫的結構描述:

uvx sqlite-utils schema data.db

你會看到以下 SQL:

CREATE TABLE "_sqlite_migrations" (
   "id" INTEGER PRIMARY KEY,
   "migration_set" TEXT,
   "name" TEXT,
   "applied_at" TEXT
);
CREATE UNIQUE INDEX "idx__sqlite_migrations_migration_set_name"
    ON "_sqlite_migrations" ("migration_set", "name");
CREATE TABLE "creatures" (
   "id" INTEGER PRIMARY KEY,
   "name" TEXT,
   "species" INTEGER,
   "weight" TEXT
);

_sqlite_migrations 資料表用來記錄哪些遷移函式已經執行過。上述 creatures 資料表的結構,就是套用完所有三個遷移之後的結果。

若要查看遷移清單,包含已套用與待處理的項目,請執行:

uvx sqlite-utils migrate data.db migrations.py --list

輸出結果:

Migrations for: creatures

  Applied:
    create_table - 2026-07-07 17:58:41.360051+00:00
    add_weight - 2026-07-07 17:58:41.360608+00:00
    change_column_types - 2026-07-07 18:01:15.802000+00:00

  Pending:
    (none)

如果你沒有指定遷移檔案,sqlite-utils migrate data.db 指令會掃描目前目錄及其子目錄中所有名為 migrations.py 的檔案,並套用在其中找到的任何 Migrations() 實例。

你也可以透過 migrations.apply(db) 方法直接在 Python 程式碼中執行遷移,這對於打造需要跨多個版本自行管理資料庫結構的工具非常有用。我自己的 LLM 工具就已經使用這個模式的某個版本好幾年了,如 llm/embeddings_migrations.py 所示。

先例

我最喜歡的此類模式實作,至今仍是 Django 的 Migrations,它由 Andrew Godwin 在其早期專案 South 的基礎上開發。有趣的是,早在 2008 年第一屆 DjangoCon 的 Schema Evolution 座談上,我、Andrew 和 Russ Keith-Magee 還各自發表了我們對 Django 結構遷移的不同方案!我的嘗試叫做 dmigrations,是與倫敦 Global Radio 的團隊一起開發的。

Django 的遷移可以從模型定義自動產生,並且具備回滾到先前版本的能力。sqlite-utils 的做法則刻意更為簡潔:與 Django 不同,sqlite-utils 鼓勵以程式化的方式建立資料表,而非透過模型定義的 ORM,因此沒有什麼東西能讓我們用來自動產生遷移。

我決定不做回滾功能,因為以我的經驗,這是一個很少被用到的功能。對於 SQLite 專案來說,要達到回滾效果最簡單的方法,就是在套用遷移之前先複製一份資料庫檔案!

從 sqlite-migrate 遷移過來

sqlite-utils 遷移功能的設計至今已有三年——我最初是將它作為一個名為 sqlite-migrate 的獨立套件發布的,它始終沒有正式脫離 beta 階段。

如今我在夠多的地方使用過那個套件,對這個設計已經很有信心,因此決定將它提升為 sqlite-utils 的內建功能,讓不斷成長的 sqlite-utils/Datasette/LLM 生態系中所有其他工具都能預設使用它。

我為 sqlite-migrate 發布了最後一個版本,將它改為依賴 sqlite-utils>=4,並將 __init__.py 檔案替換為以下內容:

from sqlite_utils import Migrations

__all__ = ["Migrations"]

任何現有依賴 sqlite-migrate 的專案應該都能在無需修改的情況下繼續正常運作。

sqlite-utils 4.0 的其他所有更新

以下是此版本的發行說明,並附上一些額外的說明:

4.0 版本包含一些小型的破壞性修正(這也是 major 版號提升的原因),並引入了三項重要的新功能:

  • 資料庫遷移,提供一套用於隨時間演進專案結構描述的結構化機制。(#752

我把遷移視為這次最具代表性的新功能,這也是撰寫這篇部落格文章的原因。

  • 巢狀交易支援透過 db.atomic() 實現,並對整個函式庫中交易的運作方式進行了多項改進。(#755

sqlite-utils 長期以來與資料庫交易的關係有點混亂,部分原因是 2018 年我剛開始設計這個函式庫時,對這些機制在 SQLite 本身是如何運作的還沒有很好的掌握。

將遷移功能加入核心函式庫,讓我下定決心要徹底解決這個難題,因為交易能讓遷移系統安全得多,也更容易推論。

最終我圍繞一個 db.atomic() 上下文管理器來打造這個功能,用法如下:

with db.atomic():
    db.table("dogs").insert({"id": 1, "name": "Cleo"}, pk="id")
    db.table("dogs").insert({"id": 2, "name": "Pancakes"})

SQLite 支援 Savepoints,因此 db.atomic() 可以巢狀使用,在交易中再執行交易。相當巧妙!

這是因為我請一個程式碼代理人審查所有未解決的 issues 和 PR,找出應該納入 4.0 版本的功能——因為如果之後才加入,就會構成破壞性變更——而它正確地指出複合外鍵正是這類功能。

我先從對 table.foreign_keys 檢視方法的一個破壞性變更著手,接著決定試試看 Claude Fable 5 能否處理將複合外鍵建立功能整合進函式庫這種更瑣碎的工作。它協助設計出的 API 讓我覺得恰到好處——與函式庫其他部分的運作方式保持一致。

其他值得注意的變更包括:

  • Upserts 現在使用 SQLite 的 INSERT ... ON CONFLICT ... DO UPDATE SET 語法,會自動偵測既有資料表的主鍵,並拒絕缺少必要主鍵值的記錄。(#652

正是這個改動最初促使我考慮以破壞性變更為由推出 4.0 版本。我打造這個功能是為了支援 sqlite-chronicle,它利用觸發器來追蹤資料表中被插入、更新或刪除的資料列。

  • db.query() 現在會立即執行,並拒絕不回傳資料列的語句;請使用 db.execute() 來執行寫入與 DDL。

這大概是最具破壞性的變更——我自己就有好幾處程式碼因此必須從 db.query() 改為 db.execute()

  • CSV 與 TSV 匯入現在預設會偵測欄位型別,而對既有資料表的插入則會保留該資料表原有的欄位型別。(#679

sqlite-utils insert data.db creatures creatures.csv --detect-types 這個旗標是後來才加入的,用來讓欄位型別(text、integer、real)能根據 CSV 中的資料自動偵測。它本來就應該是預設值,而發布 4.0 讓我得以這麼做。

  • table.extract()extracts= 不再為全為 null 的值建立對照表的記錄。(#186

這是本次版本所修復的最久遠的 issue——相關的錯誤回報(由我提出)在 2020 年 10 月就已經建立。

關於破壞性變更的詳情,請參閱 Upgrading from 3.x to 4.0

在 4.0 預發布週期中推出的功能與修正的詳細發行說明,可參閱 4.0a04.0a14.0rc14.0rc24.0rc34.0rc4

這份升級指南完全由 Claude Fable 5、Claude Opus 4.8 與 GPT-5.5 撰寫。發行說明也是如此。

這類文件正是我逐漸放心交給機器人代勞的類型。它不需要說服任何人,也不必表達任何觀點——它的任務就是盡可能準確與詳盡。我已經仔細審閱過發行說明,可以確認它們既準確又完整。

Claude Fable 5 幫了大忙

一年多前我就發布了 sqlite-utils 4.0 的第一個 alpha 版本。遲遲沒有推出穩定版,是因為要追蹤並清理許多其他細小的設計缺陷需要花費大量工夫,而 major 版本號正好讓我有機會處理這些問題。

來自 Claude Fable 5(以及在較小程度上來自 Opus 4.8 與 GPT-5.5)的協助,正好給了我克服惰性所需的助力,讓我在有限能投入這個函式庫的時間內發揮最大效益。

Fable 在 API 設計上品味非常好,而且如果你給它一個較開放的目標,它會鍥而不捨地主動出擊。我最成功的一個提示,是針對我以為是最後一個候選版本的審查任務:

review the changes on main since the last tagged 3.x release - I am about to ship them as sqlite-utils 4.0, a stable version that promises no backwards-incompatible fixes for a very long time.

review the changelog and upgrade guide, and write yourself scratch scripts to try out all of the new features in v4 - save those scripts but don't commit them

我用 Codex Desktop 裡的 GPT-5.5 xhigh 和 Claude Code 裡的 Fable 5 都試了這個提示。

GPT-5.5 寫了 5 個 Python 腳本,但沒有發現什麼特別值得注意的問題——它的最終報告在此

Fable 5 則寫了 12 個腳本,在報告中指出了 4 個阻擋發布的問題和另外 10 個問題,並打造了一個精巧的綜合重現腳本,執行後輸出如下:

=== 1. Failed db.execute() write leaves an implicit transaction open ===
  in_transaction after failed write: True
  BUG: table 'other' silently lost when connection closed

=== 2. Leading ';' bypasses the query() first-token scanner ===
  BUG: raised OperationalError: no such savepoint: sqlite_utils_query
  BUG: row persisted despite rollback (count=1)

=== 3. Rejected write PRAGMA via query() still takes effect ===
  BUG: user_version=5 after 'rejected' statement (docs say no effect)

=== 4. Implicit compound FK resolves pk columns in table order, not PK order ===
  BUG: other_columns reported as ('b', 'a'), should be ('a', 'b')
  BUG: transform of valid data raised IntegrityError: FOREIGN KEY constraint failed

=== 5. ForeignKey (now a dataclass) is no longer hashable ===
  BUG: cannot use 'sqlite_utils.db.ForeignKey' as a set element (unhashable type: 'ForeignKey')

=== 6. Mixed ForeignKey objects and tuples in foreign_keys= rejected ===
  BUG: foreign_keys= should be a list of tuples

=== 7. insert --csv into an EXISTING table transforms its column types ===
  BUG: existing zip '01234' is now 1234 (column type: int)

=== 8. insert(pk=, alter=True) regression: InvalidColumns before alter runs ===
  BUG: InvalidColumns: Invalid primary key column ['id'] for table t with columns ['a']

=== 9. migrate --stop-before an already-applied migration applies everything ===
  BUG: m2 was applied despite --stop-before m1 (m1 already applied)

=== 10. ensure_autocommit_on() silently commits an open transaction ===
  BUG: row survived rollback (count=1) - transaction was committed

我發現自己幾乎都認同這些問題。這是包含 16 個 commits 的 PR,我們在其中逐一處理了這些問題。

我毫不懷疑,如果沒有這些最新前沿模型的協助,sqlite-utils 4.0 絕不會有現在這麼高的品質。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言