sqlite-utils 4.0, now with database schema migrations

Simon Willison

sqlite-utils 4.0、データベーススキーママイグレーションに対応

原文は Simon Willison により に公開されました。 このブログを購読する

今朝、sqlite-utils 4.0をリリースした。これは同プロジェクトにとって124回目のリリースであり、2020年11月の3.0以来、初めてのメジャーバージョンアップとなる。いくつかの小さいながらも重要な破壊的変更(詳細はアップグレードガイドを参照)に加え、このバージョンでは3つの主要な新機能が導入されている。データベースマイグレーション、新しいdb.atomic()メソッドによるネストされたトランザクション、そして複合外部キーのサポートだ。

sqlite-utilsによるデータベーススキーママイグレーション

スキーママイグレーションは、SQLiteデータベースに対して行う一連の変更を定義し、さらにどのマイグレーションが適用済みかを追跡し、未適用のものがあれば適用する仕組みを提供する。

マイグレーションは、sqlite-utils Pythonライブラリを使ってPythonファイル内で定義する。このライブラリには強力なtable.transform()メソッドが含まれており、SQLiteのALTER TABLE文ではサポートされていない拡張されたALTER TABLE機能を提供する。

(table.transform()は、SQLiteドキュメントで推奨されているパターンを実装したものだ——新しいスキーマで一時テーブルを作成し、データをコピーしてから古いテーブルを削除し、一時テーブルの名前を変更するという手順である。)

以下は、creaturesというテーブルを作成し、2つ目のステップで列を追加し、3つ目で2つの列の型を変更するマイグレーションファイルの例だ。

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テーブルは、3つのマイグレーションすべてが適用された後のスキーマである。

適用済みと未適用のマイグレーション一覧を確認するには、次のコマンドを実行する。

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を参照。

先行事例

このパターンの実装として私が今でも一番気に入っているのは、Andrew Godwinが以前のプロジェクトであるSouthを基に開発したDjangoのマイグレーションだ。余談だが、2008年に開催された第1回DjangoConのSchema Evolutionパネルでは、Andrew、Russ Keith-Magee、そして私が、Django向けのスキーママイグレーションについてそれぞれ異なるアプローチを発表した。私の試みはdmigrationsと呼ばれ、ロンドンのGlobal Radioのチームと共に開発したものだった。

Djangoのマイグレーションはモデル定義から自動生成でき、以前のバージョンにロールバックする機能も備えている。sqlite-utilsのアプローチは意図的によりシンプルにしている。Djangoとは異なり、sqlite-utilsではモデル定義ORMではなくプログラムによるテーブル作成を推奨しているため、マイグレーションを自動生成するための元となるものが存在しないのだ。

ロールバック機能はあえて見送ることにした。私の経験上、めったに使われない機能だからだ。SQLiteプロジェクトであれば、マイグレーションを適用する前にデータベースファイルのコピーを作成しておけば、簡単にロールバックを実現できる。

sqlite-migrateからの移行

sqlite-utilsのマイグレーションの設計は、すでに3年前のものだ。当初はsqlite-migrateという別パッケージとしてリリースしたが、ベータ版から正式版に至ることはなかった。

そのパッケージを十分な数の場面で使ってきたことで設計に自信が持てるようになったため、成長を続けるsqlite-utils/Datasette/LLMエコシステムの他のツールすべてがデフォルトで利用できるよう、sqlite-utilsの機能として昇格させることにした。

sqlite-migrateについては最後のリリースを行い、sqlite-utils>=4に依存するように切り替え、__init__.pyファイルを次の内容に置き換えた。

from sqlite_utils import Migrations

__all__ = ["Migrations"]

sqlite-migrateに依存している既存のプロジェクトは、変更を加えることなくそのまま動作し続けるはずだ。

sqlite-utils 4.0のその他の変更点

以下がこのバージョンのリリースノートである。インラインで補足を加えている。

4.0リリースでは、いくつかの軽微な後方互換性のない修正(そのためメジャーバージョンを上げた)と、3つの主要な新機能が含まれている。

マイグレーションを今回の目玉機能と考えているため、このブログ記事を書いた。

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はセーブポイントをサポートしており、その結果db.atomic()はネストしてトランザクションの中でさらにトランザクションを実行できる。なかなか便利だ。

これは、コーディングエージェントに、後から追加すると破壊的変更になってしまうため4.0リリースに含めるべきものを、すべてのオープンなissueとPRから洗い出すよう依頼したことがきっかけだった。エージェントは、複合外部キーこそがまさにその種の機能であると正しく指摘したのだ。

まずは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であり、根本的なバグは(私自身によって)2020年10月に登録されたものだった。

後方互換性のない変更の詳細については、3.xから4.0へのアップグレードを参照。

4.0のプレリリースサイクルで提供された機能や修正に関する詳細なリリースノートは、4.0a04.0a14.0rc14.0rc24.0rc3および4.0rc4で確認できる。

アップグレードガイドはClaude Fable 5、Claude Opus 4.8、GPT-5.5によって全面的に執筆された。リリースノートも同様だ。

これは、私が徐々にロボットへのアウトソースに抵抗がなくなってきた種類のドキュメントだ。誰かを説得したり、意見を述べたりする必要はなく、その役割は可能な限り正確かつ詳細であることだ。リリースノートは私自身が綿密にレビューし、正確かつ網羅的であることを確認している。

Claude Fable 5の多大な貢献

sqlite-utils 4.0の最初のアルファ版は1年以上前にリリースした。安定版のリリースを先延ばしにしてきたのは、メジャーバージョンアップだからこそ取り組める数多くの細かな設計上の欠陥を洗い出して修正するのに多大な作業が必要だったからだ。

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件の追加のissueを特定し、さらに巧妙な統合再現スクリプトを作成した。これを実行すると次のような出力が得られた。

=== 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」を使用して翻訳されました。

コメント