Simon Willison · 博客

sqlite-utils 4.0,现已支持数据库 schema 迁移

sqlite-utils 4.0, now with database schema migrations

二〇二六年七月七日 · 英文原文

Simon Willison 发布了 sqlite-utils 4.0,这是该项目的第124个版本,也是自2020年3.0以来的首个主版本号提升。新特性包括数据库迁移(database migrations)、通过`db.atomic()`方法实现的嵌套事务支持、复合外键(compound foreign keys)以及upserts改用SQLite的`INSERT ... ON CONFLICT ... DO UPDATE SET`语法。迁移系统使用Python文件定义schema变更,并通过`_sqlite_migrations`表追踪执行状态。Claude Fable 5、Claude Opus 4.8和GPT-5.5协助编写了升级指南和发布说明,并识别出10个发布阻塞问题。

今天早上我发布了 sqlite-utils 4.0,这是该项目的第 124 个版本,也是自 2020 年 11 月 3.0 以来的第一个主版本号提升。除了少量但重要的破坏性变更(详见本升级指南),此版本引入了三大主要特性:数据库迁移(database migrations)、嵌套事务(通过新的 db.atomic() 方法)以及复合外键(compound foreign keys)支持。

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

Schema 迁移定义了一系列对 SQLite 数据库的变更操作,并附带一个机制来追踪哪些迁移已被应用,以及应用任何待处理的迁移。迁移使用 sqlite-utils Python 库在 Python 文件中定义,该库包含一个强大的 table.transform() 方法,提供了 SQLite 的 ALTER TABLE 语句所不支持的增强表修改能力。(table.transform() 实现了 SQLite 文档推荐的模式——创建一个具有新 schema 的临时表,复制数据,然后删除旧表并将临时表重命名为原表名。)

以下是一个迁移文件示例,它首先创建一个名为 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

接着检查该数据库的 schema:

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 表是应用所有三个迁移后的 schema。

要查看迁移列表(包括待处理和已应用的),运行:

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) 方法来执行迁移,这对于构建需要跨多个版本管理自身数据库 schema 的工具非常有用。我自己的 LLM 工具已经使用这种模式好几年了,如 llm/embeddings_migrations.py 所示。

先例

我最喜欢的这种模式的实现仍然是 Django 的 Migrations,由 Andrew Godwin 基于他早期的项目 South 开发。有趣的事实:Andrew、Russ Keith-Magee 和我在 2008 年第一届 DjangoCon 的 Schema Evolution 小组讨论中,展示了我们各自为 Django 设计的 schema 迁移方案!我的尝试叫做 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 版本包含一些微小的向后不兼容修复(因此主版本号提升),并引入了三大主要新特性:

  • 数据库迁移,提供了一种结构化机制来随时间演变项目的 schema。(#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() 可以嵌套,在事务内部执行事务。这非常简洁!

  • 支持复合外键,包括通过 table.foreign_keys 进行创建、转换和内省。(#594)

这是在我要求一个编码 agent 审查所有未解决的问题和 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() 现在立即执行,并拒绝不返回行的语句;写入和 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)

这是此版本解决的最古老的问题——底层 bug 是在 2020 年 10 月(由我)打开的。

关于向后不兼容变更的详细信息,请参阅从 3.x 升级到 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 版本。我一直拖延稳定版本的发布,因为要追踪和清理主版本号允许我处理的许多其他微小设计缺陷需要大量工作。Claude Fable 5(以及较小程度上的 Opus 4.8 和 GPT-5.5)的帮助给了我克服惯性所需的推动力,并充分利用我能花在这个库上的时间。Fable 在 API 设计方面品味很好,如果你给它一个更开放的目标,它会非常主动。

我最成功的 prompt 是我针对我认为的最后一个候选版本发布的一个审查任务:审查自上次标记的 3.x 版本以来 main 分支上的变更——我即将将它们作为 sqlite-utils 4.0 发布,这是一个稳定版本,承诺在很长一段时间内不会有向后不兼容的修复。审查 changelog 和升级指南,并为自己编写 scratch 脚本来尝试 v4 中的所有新特性——保存这些脚本但不要提交。

我在 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 的质量明显高于我在没有最新前沿模型帮助下构建的版本。

标签:schema-migrations, projects, sqlite, ai, sqlite-utils, annotated-release-notes, generative-ai, llms, ai-assisted-programming, anthropic, claude, agentic-engineering, claude-mythos-fable

译自 Simon Willison · 博客 · 录于 二〇二六年七月七日