sqlite-utils 4.0, now with database schema migrations

Simon Willison

sqlite-utils 4.0 发布,现已支持数据库结构迁移

原文由 Simon Willison 发布,订阅该博客

今天早上我发布了sqlite-utils 4.0,这是该项目的第 124 个版本,也是自3.0于 2020 年 11 月发布以来的首次大版本更新。除了一些虽小但重要的破坏性变更(详见这份升级指南),新版本还带来了三项主要新特性:数据库迁移嵌套事务(通过新的 db.atomic() 方法实现)以及对复合外键的支持。

使用 sqlite-utils 进行数据库结构迁移

结构迁移定义了一系列要对 SQLite 数据库执行的变更,并提供了一套机制来跟踪哪些迁移已应用、自动执行待处理的迁移。

迁移通过 Python 文件来定义,使用的是sqlite-utils Python 库,其中包含强大的 table.transform() 方法,提供了 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() 实例。

你也可以在 Python 代码中通过 migrations.apply(db) 方法执行迁移,这对于构建需要在多个版本中自行管理数据库结构的工具非常有用。我自己的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 版本包含一些虽小但不向后兼容的修复(因此提升了主版本号),并引入了三项主要新特性:

我把迁移视为本次更新的标志性新特性,这也是写这篇博文的原因。

长久以来,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 支持Savepoint,因此 db.atomic() 可以嵌套使用,从而在事务中再执行事务。非常巧妙!

这个特性的加入,源于我让一个编程智能体审查所有未解决的 issue 和 PR,找出那些应该纳入 4.0 版本的内容——因为如果以后再加,它们就会成为破坏性变更——而它准确地指出,复合外键正是这类特性。

我先对table.foreign_keys 这一内省方法做了一处破坏性改动,然后想看看 Claude Fable 5 能否胜任将复合外键创建集成到库中这项更为繁琐的工作。它协助设计的 API 让我觉得恰到好处——与库中其他部分的风格保持了一致,正合我意

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

  • Upsert 现在采用 SQLite 的 INSERT ... ON CONFLICT ... DO UPDATE SET 语法,会自动检测既有表的主键,并拒绝缺少必需主键值的记录。(#652

正是这一改动最初促使我考虑以破坏性变更为由发布 4.0 大版本。我做这个改动是为了支持 sqlite-chronicle,它通过触发器来跟踪表中被插入、更新或删除的行。

  • db.query() 现在会立即执行,并拒绝不返回行的语句;如需执行写入和 DDL 操作,请使用 db.execute()

这可能是最具破坏性的变更——为此我也不得不在自己的代码中多处将 db.query() 改为 db.execute()

  • CSV 和 TSV 导入现在默认会自动检测列类型,而向已存在表中插入数据时会保留该表原有的列类型。(#679

sqlite-utils insert data.db creatures creatures.csv --detect-types 这个标志是后来才加上去的,用于根据 CSV 中的数据自动检测列类型(text、integer、real)。它本就应该是默认行为,而发布 4.0 正好让我可以将其设为默认。

  • table.extract()extracts= 不再为全为 null 的值创建关联表的记录。(#186

这是本版本所修复的最早的一个 issue——这个底层 bug 还是我在 2020 年 10 月提交的。

有关不向后兼容变更的详情,请参阅从 3.x 升级到 4.0

在 4.0 预发布周期中发布的功能和修复的详细说明,可在 4.0a04.0a14.0rc14.0rc24.0rc34.0rc4 中查看。

这份升级指南完全由 Claude Fable 5、Claude Opus 4.8 和 GPT-5.5 编写。发布说明也是如此。

这类文档正是我慢慢开始放心交给 AI 来写的类型。它不需要说服任何人,也不必表达观点——它的任务就是尽可能准确、详尽。我已仔细审阅过这些发布说明,可以确认它们准确且全面。

Claude Fable 5 帮了大忙

我在一年多前就发布了 sqlite-utils 4.0 的首个 alpha 版本,至今已有一年多。之所以迟迟没有发布正式版,是因为要借着大版本更新的机会,去追踪并清理其他许多细小的设计缺陷,这需要做大量的工作。

来自 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 个提交的 PR

我毫不怀疑,有了这些最新前沿模型的协助,sqlite-utils 4.0 的质量要比我独自开发时高得多。

本文章由 muse-spark-1.2-contributor 进行翻译

评论