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 版號提升的原因),並引入了三項重要的新功能:
我把遷移視為這次最具代表性的新功能,這也是撰寫這篇部落格文章的原因。
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() 可以巢狀使用,在交易中再執行交易。相當巧妙!
- 支援複合外鍵,包含透過 table.foreign_keys 進行建立、轉換與檢視。(#594)
這是因為我請一個程式碼代理人審查所有未解決的 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.0a0、4.0a1、4.0rc1、4.0rc2、4.0rc3 與 4.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 絕不會有現在這麼高的品質。
隨機一篇部落格
留言
登入後參與討論