sqlite-utils 4.0 加入模式迁移

Simon Willison··作者 Simon Willison

关键信息

迁移系统使用 Python 文件和 sqlite-utils 的 Migrations API,并依赖 table.transform() 来完成 SQLite 原生 ALTER TABLE 不能完整支持的结构变更。此次发布还提供了破坏性变更的升级指南,而且迁移流程会跟踪哪些步骤已经执行过,再应用尚未完成的迁移。

资讯摘要

Simon Willison 宣布发布 sqlite-utils 4.0,这是该项目的第 124 次发布,也是自 2020 年 11 月 3.0 版本以来的首次大版本升级。此次发布包含一些小而重要的破坏性变更,作者在升级指南中做了说明。最引人注意的三项新功能是数据库迁移、通过 db.atomic() 支持的嵌套事务,以及对复合外键的支持。新的迁移系统允许开发者在 Python 文件中定义模式变更,并跟踪哪些迁移已经执行过。

它基于 sqlite-utils 的 Python 库实现,并使用 table.transform() 来完成 SQLite 内置 ALTER TABLE 无法完全支持的结构编辑。文中说明,table.transform() 遵循 SQLite 文档推荐的做法:先创建一个带新模式的临时表,再把数据复制过去,最后删除旧表并将临时表重命名。文章还给出了一个迁移示例:先创建 creatures 表,再增加 weight 列,最后修改两列的数据类型。作者同时展示了如何用 sqlite-utils 命令行工具运行迁移脚本,并检查数据库最终的 schema。

资讯正文

今天早上我发布了 <a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0">sqlite-utils 4.0</a>,这是该项目的第 124 次发布,也是自 2020 年 11 月 <a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v3-0">3.0</a> 以来的首次重大版本升级。除了若干小而重要的破坏性变更(详见<a href="https://sqlite-utils.datasette.io/en/stable/upgrading.html">这份升级指南</a>)之外,这个版本还引入了三项主要功能:<strong>数据库迁移</strong>、<strong>嵌套事务</strong>(通过一个新的 <code>db.atomic()</code> 方法实现),以及对<strong>复合外键</strong>的支持。

<h4 id="database-schema-migrations-using-sqlite-utils">使用 sqlite-utils 进行数据库模式迁移</h4>

模式迁移定义了一系列要对 SQLite 数据库进行的更改,并提供了一种机制,用于跟踪哪些迁移已经应用,以及应用任何被发现尚待执行的迁移。

迁移通过 Python 文件来定义,使用的是 <a href="https://sqlite-utils.datasette.io/en/stable/python-api.html">sqlite-utils Python 库</a>。该库包含一个功能强大的 <code>table.transform()</code> 方法,提供 <a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#transforming-a-table">增强的 alter table 能力</a>,而这是 SQLite 的 <code>ALTER TABLE</code> 语句所不支持的。

(<code>table.transform()</code> 实现了 <a href="https://www.sqlite.org/lang_altertable.html#otheralter">SQLite 文档推荐</a>的模式——创建一个具有新模式的新临时表,复制数据过去,然后删除旧表,并将临时表重命名为原表。)

下面是一个示例迁移文件:它先创建一个名为 <code>creatures</code> 的表,再在第二步为其添加一个额外列,最后在第三步更改其中两个列的类型:

<pre><span class="pl-k">from</span> <span class="pl-s1">sqlite_utils</span> <span class="pl-k">import</span> <span class="pl-v">Migrations</span>

<span class="pl-s1">migrations</span> <span class="pl-c1">=</span> <span class="pl-en">Migrations</span>(<span class="pl-s">"creatures"</span>)

<span class="pl-en">@<span class="pl-en">migrations</span>()</span>

<span class="pl-k">def</span> <span class="pl-en">create_table</span>(<span class="pl-s1">db</span>):

<span class="pl-s1">db</span>[<span class="pl-s">"creatures"</span>].<span class="pl-c1">create</span>(

{<span class="pl-s">"id"</span>: <span class="pl-s1">int</span>, <span class="pl-s">"name"</span>: <span class="pl-s1">str</span>, <span class="pl-s">"species"</span>: <span class="pl-s1">str</span>},

<span class="pl-s1">pk</span><span class="pl-c1">=</span><span class="pl-s">"id"</span>,

<span class="pl-en">@<span class="pl-en">migrations</span>()</span>

<span class="pl-k">def</span> <span class="pl-en">add_weight</span>(<span class="pl-s1">db</span>):

<span class="pl-s1">db</span>[<span class="pl-s">"creatures"</span>].<span class="pl-c1">add_column</span>(<span class="pl-s">"weight"</span>, <span class="pl-s1">float</span>)</pre>

<span class="pl-en">@<span class="pl-en">migrations</span>()</span>

<span class="pl-k">def</span> <span class="pl-en">change_column_types</span>(<span class="pl-s1">db</span>):

<span class="pl-s1">db</span>[<span class="pl-s">"creatures"</span>].<span class="pl-c1">transform</span>(<span class="pl-s1">types</span><span class="pl-c1">=</span>{<span class="pl-s">"species"</span>: <span class="pl-s1">int</span>, <span class="pl-s">"weight"</span>: <span class="pl-s1">str</span>})

把这段保存为 <code>migrations.py</code>,然后像这样在一个全新的数据库上运行它:

<div class="highlight highlight-source-shell"><pre>uvx sqlite-utils migrate data.db migrations.py</pre></div>

然后如果你检查这个数据库的 schema:

<div class="highlight highlight-source-shell"><pre>uvx sqlite-utils schema data.db</pre></div>

你会看到如下 SQL:

<div class="highlight highlight-source-sql"><pre><span class="pl-k">CREATE</span> <span class="pl-k">TABLE</span> "<span class="pl-en">_sqlite_migrations</span>" (

<span class="pl-s"><span class="pl-pds">"</span>id<span class="pl-pds">"</span></span> <span class="pl-k">INTEGER</span> <span class="pl-k">PRIMARY KEY</span>,

<span class="pl-s"><span class="pl-pds">"</span>migration_set<span class="pl-pds">"</span></span> <span class="pl-k">TEXT</span>,

<span class="pl-s"><span class="pl-pds">"</span>name<span class="pl-pds">"</span></span> <span class="pl-k">TEXT</span>,

<span class="pl-s"><span class="pl-pds">"</span>applied_at<span class="pl-pds">"</span></span> <span class="pl-k">TEXT</span>

);

<span class="pl-k">CREATE</span> <span class="pl-k">UNIQUE INDEX</span> "<span class="pl-en">idx__sqlite_migrations_migration_set_name</span>"

<span class="pl-k">ON</span> <span class="pl-s"><span class="pl-pds">"</span>_sqlite_migrations<span class="pl-pds">"</span></span> (<span class="pl-s"><span class="pl-pds">"</span>migration_set<span class="pl-pds">"</span></span>, <span class="pl-s"><span class="pl-pds">"</span>name<span class="pl-pds">"</span></span>);

<span class="pl-k">CREATE</span> <span class="pl-k">TABLE</span> "<span class="pl-en">creatures</span>" (

<span class="pl-s"><span class="pl-pds">"</span>id<span class="pl-pds">"</span></span> <span class="pl-k">INTEGER</span> <span class="pl-k">PRIMARY KEY</span>,

<span class="pl-s"><span class="pl-pds">"</span>name<span class="pl-pds">"</span></span> <span class="pl-k">TEXT</span>,

<span class="pl-s"><span class="pl-pds">"</span>species<span class="pl-pds">"</span></span> <span class="pl-k">INTEGER</span>,

<span class="pl-s"><span class="pl-pds">"</span>weight<span class="pl-pds">"</span></span> <span class="pl-k">TEXT</span>

);</pre></div>

<code>_sqlite_migrations</code> 表用于跟踪哪些迁移函数已经运行过。上面的 <code>creatures</code> 表是在应用了全部三个迁移之后的 schema。

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

<div class="highlight highlight-source-shell"><pre>uvx sqlite-utils migrate data.db migrations.py --list</pre></div>

输出:

<pre><code>Migrations for: creatures

Applied:</code></pre>

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

待处理:

(无)

如果你不指定迁移文件,<code>sqlite-utils migrate data.db</code> 命令会扫描当前目录及其子目录中名为 <code>migrations.py</code> 的文件,并应用它找到的任何 <code>Migrations()</code> 实例。

你也可以使用 <a href="https://sqlite-utils.datasette.io/en/stable/migrations.html#applying-migrations-in-python">Python 代码</a> 通过 <code>migrations.apply(db)</code> 方法执行迁移,这对构建管理自己数据库模式、并跨多个版本演进的工具很有用。我的 <a href="https://llm.datasette.io/">LLM 工具</a> 多年来一直在使用这种模式的一个版本,如 <a href="https://github.com/simonw/llm/blob/0.31/llm/embeddings_migrations.py">llm/embeddings_migrations.py</a> 所示。

### 前例

我最喜欢的这种模式的实现仍然是 <a href="https://docs.djangoproject.com/en/6.0/topics/migrations/">Django 的 Migrations</a>,它由 Andrew Godwin 基于他早先的项目 <a href="https://github.com/andrewgodwin/south">South</a> 开发而成。有趣的是,Andrew、Russ Keith-Magee 和我早在 2008 年第一届 DjangoCon 上,就在 <a href="https://www.youtube.com/watch?v=VSq8m00p1FM">Schema Evolution panel</a> 上展示过我们在 Django 中处理模式迁移的竞争性方案!我的尝试叫作 <a href="https://simonwillison.net/2008/Sep/3/dmigrations/">dmigrations</a>,由我在伦敦的 Global Radio 团队共同开发。

Django 的迁移可以根据模型定义自动生成,并且支持回滚到先前版本。<code>sqlite-utils</code> 的做法则刻意更简单:不同于 Django,<code>sqlite-utils</code> 鼓励以编程方式创建表,而不是使用模型定义 ORM,因此我们没有什么东西可以用来自动生成迁移。

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

### 从 sqlite-migrate 迁移

<code>sqlite-utils</code> 的迁移设计已经有三年历史了——我最初是把它作为一个名为 <a href="https://github.com/simonw/sqlite-migrate">sqlite-migrate</a> 的独立包发布的,它始终没有真正超越 beta 版本。

我已经在足够多的地方使用过这个包,因此对它的设计很有信心,所以我决定把它提升为 <code>sqlite-utils</code> 的一个功能,让 sqlite-utils/Datasette/LLM 这个不断壮大的生态系统中的其他工具都能默认使用它。

我为 <code>sqlite-migrate</code> 做了 <a href="https://github.com/simonw/sqlite-migrate/releases/tag/0.2">最后一个版本</a>,将其改为依赖 <code>sqlite-utils&gt;=4</code>,并把 <code>__init__.py</code> 文件替换为如下内容:

from sqlite_utils import Migrations

__all__ = ["Migrations"]

任何依赖于 sqlite-migrate 的现有项目都应当可以在不作任何改动的情况下继续运行。

sqlite-utils 4.0 中的其他内容

以下是这个版本的发布说明,并附有一些行内注释:

4.0 版本包含一些向后不兼容的细微修复(因此主版本号提升了),并引入了三项重要的新功能:

数据库迁移,为随着时间推移演进项目架构提供了一种结构化机制。(#752)

我认为迁移是最具代表性的新功能,因此写了这篇博文。

通过 db.atomic() 提供的嵌套事务支持,以及库中关于事务处理的大量改进。(#755)

sqlite-utils 长期以来与数据库事务的关系一直有些混乱,部分原因是我在 2018 年开始设计这个库时,对 SQLite 本身中的事务机制还没有很深的理解。

把迁移功能加入核心库后,我下定决心要把这个问题彻底解决,因为事务会让迁移系统安全得多,也更容易推理。

我最终是围绕一个 db.atomic() 上下文管理器构建的,它看起来像这样:

SQLite 支持 Savepoints,因此 db.atomic() 可以嵌套使用,在事务中再执行事务。这个功能相当不错!

<li>支持 <a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#python-api-compound-foreign-keys">复合外键</a>,包括通过 <a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#python-api-introspection-foreign-keys">table.foreign_keys</a> 进行创建、转换和检查。(<a href="https://github.com/simonw/sqlite-utils/issues/594">#594</a>)</li>

</ul>

</blockquote>

<p>这件事的起因是,我请一个编码代理审查所有尚未关闭的问题和 PR,看看有哪些应该包含在 4.0 版本里的内容,因为如果我之后再加进去,它们就会构成破坏性变更。它正确地识别出,复合外键正是这类功能。</p>

<p>我先从对 <a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#python-api-introspection-foreign-keys">table.foreign_keys</a> 检查方法的一个破坏性变更开始,然后决定看看 Claude Fable 5 能否处理更棘手的工作:把复合外键的<em>创建</em>集成进这个库。它帮助设计出的 API 在我看来<a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#compound-foreign-keys">恰到好处</a>——与该库其余部分现有的工作方式保持一致。</p>

<blockquote>

<p>其他值得注意的变化包括:</p>

<ul>

<li>Upsert 现在使用 SQLite 的 <code>INSERT ... ON CONFLICT ... DO UPDATE SET</code> 语法,会自动检测现有表的主键,并拒绝那些缺少必需主键值的记录。(<a href="https://github.com/simonw/sqlite-utils/issues/652">#652</a>)</li>

</ul>

</blockquote>

<p>这项改动是我第一次开始考虑将版本号提升到会包含破坏性变更的 4.0 的原因。我构建它是为了帮助支持 <a href="https://github.com/simonw/sqlite-chronicle">sqlite-chronicle</a>,后者使用触发器来跟踪表中已插入、已更新或已删除的行。</p>

<blockquote>

<ul>

<li>

<code>db.query()</code> 现在会立即执行,并会拒绝不返回行的语句;写操作和 DDL 请使用 <code>db.execute()</code>。</li>

</ul>

</blockquote>

<p>这大概是<a href="https://sqlite-utils.datasette.io/en/stable/upgrading.html#python-api-changes">最具破坏性的变更</a>——我不得不在自己代码中的几个地方把 <code>db.query()</code> 改成 <code>db.execute()</code>。</p>

<blockquote>

<ul>

<li>CSV 和 TSV 导入现在默认会检测列类型,而向现有表插入数据时则会保留这些表的列类型。(<a href="https://github.com/simonw/sqlite-utils/issues/679">#679</a>)</li>

</ul>

</blockquote>

<p><code>sqlite-utils insert data.db creatures creatures.csv --detect-types</code> 这个标志是后来才加入的,用来根据 CSV 中的数据自动检测列类型(text、integer、real)。它理应成为默认行为,而发布 4.0 就意味着我可以把它变成默认。</p>

<blockquote>

<ul>

<li>

<code>table.extract()</code> 和 <code>extracts=</code> 不再为全 <code>null</code> 值创建查找表记录。(<a href="https://github.com/simonw/sqlite-utils/issues/186">#186</a>)</li>

</ul>

</blockquote>

<p>这是本次发布中最早被解决的问题——其底层 bug 最初是在 2020 年 10 月由我提交的。</p>

<blockquote>

<p>有关不向后兼容变更的详细信息,请参见 <a href="https://sqlite-utils.datasette.io/en/stable/upgrading.html#upgrading-3-to-4">从 3.x 升级到 4.0</a>。</p>

<p>在 4.0 预发布周期中发布的功能和修复的详细版本说明,见 <a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0a0">4.0a0</a>、<a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0a1">4.0a1</a>、<a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0rc1">4.0rc1</a>、<a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0rc2">4.0rc2</a>、<a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0rc3">4.0rc3</a> 和 <a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0rc4">4.0rc4</a>。</p>

</blockquote>

<p>升级指南完全由 Claude Fable 5、Claude Opus 4.8 和 GPT-5.5 撰写。发布说明也是如此。</p>

<p>这就是我逐渐开始放心交给机器人去做的那类文档。它不需要说服任何人,也不需要表达观点——它的任务是尽可能准确、详尽。我仔细审阅了这些发布说明,可以确认它们准确且全面。</p>

<h4 id="claude-fable-5-helped-a-lot">Claude Fable 5 帮了大忙</h4>

<p>我在 <a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#a0-2025-05-08">一年多前</a>发布了 sqlite-utils 4.0 的第一个 alpha 版本。之所以一直拖着没发稳定版,是因为要追查并修复许多其他细微的设计缺陷,而允许我以主版本号升级,意味着我可以把这些问题一并处理掉,这需要相当多的工作。</p>

<p>Claude Fable 5(以及程度稍轻的 Opus 4.8 和 GPT-5.5)的帮助,给了我恰到好处的推动力,让我克服了惰性,并把我能为这个库投入的时间尽可能用到位。</p>

<p>Fable 在 API 设计上 <em>真的很有品味</em>,如果你给它一个更开放的目标,它就会 <a href="https://simonwillison.net/2026/Jun/11/fable-is-relentlessly-proactive/">极其积极主动</a>。我最成功的一次提示词,是针对我以为已经是最后一个发布候选版的版本发起的一项审查任务:</p>

<blockquote>

<p><code>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.</code></p>

<p><code>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</code></p>

</blockquote>

<p>我在 Codex Desktop 里用 GPT-5.5 xhigh、以及在 Claude Code 里用 Fable 5 试了这个方法。</p>

GPT-5.5 <a href="https://gist.github.com/simonw/823fdecc031371d56dce39537adc0096">写了 5 个 Python 脚本</a>,但没有冒出什么特别有意思的内容——它的<a href="https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4899982463">最终报告在这里</a>。

Fable 5 <a href="https://gist.github.com/simonw/95800bf584f8e437f1cf0d48d9ef81e6">写了 12 个脚本</a>,在<a href="https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4900034150">报告中</a>识别出 4 个阻塞发布的问题和另外 10 个问题,还构建了一个很巧妙的<a href="https://gist.githubusercontent.com/simonw/95800bf584f8e437f1cf0d48d9ef81e6/raw/c43918b36a129bba1d2f2a129117aa11c85146c0/12_bug_repros.py">合并复现脚本</a>,运行后输出如下:

<pre><code>=== 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

</code></pre>

我发现自己几乎完全同意它们的判断。这里是<a href="https://github.com/simonw/sqlite-utils/pull/779">包含 16 次提交的 PR</a>,我们按顺序逐一处理了这些问题。

毫无疑问,在我的心目中,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

来源与参考

  1. 原始链接
  2. sqlite-utils 4.0, now with database schema migrations