2026年7月7日

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

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

架构迁移定义了对 SQLite 数据库进行更改的序列,以及跟踪已应用迁移并应用任何待处理迁移的机制。

迁移使用 sqlite-utils Python 库在 Python 文件中定义,该库包含强大的 table.transform() 方法,提供 SQLite 的 ALTER TABLE 语句不支持的增强型 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 项目开发。有趣的事实:Andrew、Russ Keith-Magee 和我在 2008 年首届 DjangoCon 的 Schema Evolution 专题讨论上展示了我们针对 Django 的架构迁移的竞争方案!我的尝试被称为 dmigrations,是与伦敦 Global Radio 的团队共同开发的。

Django 的迁移可以从模型定义自动生成,并包括回滚到先前版本的能力。sqlite-utils 的方法故意更简单:与 Django 不同,sqlite-utils 鼓励以编程方式创建表,而不是使用模型定义 ORM,因此我们没有可以用来自动生成迁移的内容。

我决定跳过回滚,因为根据我的经验,这是一个很少使用的功能。对于 SQLite 项目,实现回滚的简单方法是在应用迁移之前创建数据库文件的副本!

从 sqlite-migrate 迁移

sqlite-utils 迁移的设计已有三年历史——我最初将其作为一个名为 sqlite-migrate 的独立包发布,该包从未完全脱离测试版。

我已经在足够多的地方使用了该包,对设计感到自信,因此决定将其提升为 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 支持 Savepoints,因此 db.atomic() 可以嵌套以在事务内执行事务。非常巧妙!

这是因为我要求一个编码代理审查所有未解决的问题和 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 标志是后来添加的,用于根据 CSV 中的数据自动检测列类型(文本、整数、实数)。它应该成为默认值,发布 4.0 意味着我可以做到这一点。

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

此版本解决的最早问题——底层 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 编写。发布说明也是如此。

这是我慢慢习惯外包给机器人的文档类型。它不需要说服任何人,也不需要表达任何意见——它的工作是尽可能准确和详细。我已仔细审查了发布说明,并确认它们准确且全面。

Claude Fable 5 提供了很大帮助

我在一年前多就发布了 sqlite-utils 4.0 的第一个 alpha 版本 over a year ago。我一直在拖延稳定版的发布,因为跟踪和清理主版本号允许我处理的许多其他小设计缺陷需要大量工作。

来自 Claude Fable 5(以及在较小程度上 Opus 4.8 和 GPT-5.5)的帮助给了我克服惯性的动力,并充分利用了我能花在这个库上的时间。

Fable 在 API 设计方面有非常好的品味,如果你给它一个更开放的目标,它会 relentlessly proactive。我最成功的提示是一个针对我认为的最后一个发布候选的审查任务:

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 个额外问题 in its report,并构建了一个巧妙的组合重现脚本,运行时输出以下内容:

=== 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 的质量将远低于现在的水平。