プログラミング
SQLite には (Rust スタイルの) エディションが必要
SQLite should have (Rust-style) editions (mort.coffee)
要約
この記事では、SQLite のデフォルト設定における2つの大きな問題点を指摘しています。一つは、外部キー制約がデフォルトで無効になっていることで、データの一貫性が損なわれる可能性があることです。もう一つは、列の型定義が緩いため、意図しないデータ型が格納されてしまう可能性があることです。これらの問題に対する解決策として、`PRAGMA foreign_keys = ON;` や `strict` テーブルの使用が提案されていますが、グローバルな設定がないことが課題として挙げられています。
全文翻訳
home projects blurbssqlite should have (rust-style) editions
date: 2026-07-15
git: https://gitlab.com/mort96/blog/blob/published/content/00000-home/00017-sqlite-editions.md
sqliteは驚くべきデータベースエンジンです。私は多くの組み込みプロジェクトでデータベースとしてsqliteを使用しており、ローカルデータストレージの業界標準と呼んでも過言ではないと思います。一部のサーバーソフトウェアでさえsqliteを使用しています。例えば、lobste.rs は現在sqliteで稼働しています。従来のrdbms(リレーショナルデータベース管理システム)とは異なり、sqliteは独立したプロセスではありません。ライブラリとしてのrdbmsであり、ソフトウェアは自己完結型であり続けます。従来のファイル形式とは異なり、カスタムシリアライザーやパーサーを記述する必要はありません。ある意味では、両方の世界の最良の部分を備えています。
しかし、ただ一つ大きな問題があります。それは、デフォルト設定がすべて間違っているということです。
悪いデフォルトその1:外部キー制約がデフォルトで無視される
お読みの通りです。外部キー制約は、データベースの一貫性を保ち、ぶら下がった参照(dangling references)が発生しないようにするための、私たちが持つ主要なツールと言えるでしょう。簡単な説明として、sqlの外部キー制約は以下のようになります。
create table users (
id integer primary key,
display_name text
);
create table posts (
id integer primary key,
user_id integer not null,
content text not null,
foreign key(user_id) references users(id)
);
他のすべてのrdbmsにおける典型的な動作は、postのuser_id列が常に有効なユーザーのidを参照しなければならないということです。有効なユーザーidを提供せずに新しいpostを作成することはできませんし、外部キー制約違反のエラーが発生するのを避けるために、ユーザーを削除する際にはそのユーザーのpostも一緒に削除しなければなりません。
sqliteだけが、これをデフォルトで強制しないrdbmsです。sqliteのrowidを再利用する傾向があるため、これはさらに悪化します。この例では、これらのinteger primary keyの行は、テーブルのrowidのエイリアスになります。rowidはsqliteのテーブルの各行に割り当てられる一意の整数idです。rowidを割り当てるアルゴリズムは少し複雑ですが(詳細はsqliteのドキュメントを参照)、場合によってはidの再利用につながります。これは、ぶら下がった参照が誤った列への参照につながりやすく、すべてが正常に見えるため、ぶら下がった参照よりもさらに悪い結果になります。ルックアップ中にエラーさえ発生しません。
このおもちゃのデータベーススキーマでの操作の仮想的なシーケンスを見てみましょう。
-- ボブがアカウントを作成します
insert into users (display_name) values ('Bob');
select * from users;
-- id | display_name
-- 1 | Bob
-- ボブが紹介投稿をします
insert into posts (user_id, content) values (1, 'Hello, I am Bob');
select u.display_name, p.content from users as u, posts as p where u.id = p.user_id;
-- display_name | content
-- Bob | Hello, I am Bob
-- ボブがアカウントを削除します
-- しかし、投稿を削除するのを忘れました。
-- sqliteは外部キーを無視するため、エラーを生成しません。
delete from users where id = 1;
-- アリスがアカウントを作成します。
-- アリスはrowidアルゴリズムにより、ボブと同じidを取得します。
insert into users (display_name) values ('Alice');
select * from users;
-- id | display_name
-- 1 | Alice
-- アリスはボブの古い投稿を引き継いでしまいました!
select u.display_name, p.content from users as u, posts as p where u.id = p.user_id;
-- display_name | content
-- Alice | Hello, I am Bob
修正方法は、プラグマでforeign_keysを有効にすることです。
pragma foreign_keys = ON;
もし最初からこれを実行していれば、バグのあるDELETE操作はエラーを生成したでしょう。
delete from users where id = 1;
-- ランタイムエラー: foreign key constraint failed (19)
悪いデフォルトその2:列が間違ったデータ型を格納できる
sqliteはシンプルな型システムを持っています。値はnull、integer、real(倍精度浮動小数点数)、text、またはblob(バイナリデータ)のいずれかです。したがって、列はこれらの型のいずれかの値を格納するように定義できます。しかし、integerとして定義された列は、整数のみに限定されません。代わりに、sqliteはその列を「integerアフィニティ」を持つとみなします。
これは実質的に次のような意味です。
text値を挿入しようとした場合、それが整数の有効な文字列表現であれば、整数に変換されて格納されます。実数の有効な文字列表現であるtext値を挿入しようとした場合、real(倍精度浮動小数点数)に変換されて格納されます。それ以外の場合、値はそのまま格納されます。
他のアフィニティには、異なるがより単純なルールがあります。
blobアフィニティを持つ列は値をそのまま格納します。
textアフィニティを持つ列は、blob、text、null値をそのまま格納しますが、数値はtextに変換します。
realアフィニティを持つ列は、integer値がrealに変換される点を除き、integerアフィニティを持つ列と同様に機能します。
これが実際にはどのように見えるかを見てみましょう。
create table music (
id integer primary key,
name text,
duration_sec integer
);
insert into music (name, duration_sec) values ('Lost In Hollywood', 321);
insert into music (name, duration_sec) values ('Comfortably Numb', 382);
insert into music (name, duration_sec) values ('The Way of All Flesh', 'Way too long, I mean come on');
select * from music;
-- id | name | duration_sec
-- 1 | Lost In Hollywood | 321
-- 2 | Comfortably Numb | 382
-- 3 | The Way of All Flesh | Way too long, I mean come on
データベースがデータ検証に対してこれほど無頓着であるのがなぜ悪い考えなのか、説明する必要はないと思います。sqliteが明示的に動的型付けのドキュメントデータベースであればまだしも、そうではありません。sqliteは構文ルールを通じて私に尋ねます。「この列にどのような型を入れたいですか?」
私はかつて、あるプロジェクトで、ブール値(1と0)を格納することを意図した列に、誤って文字列 '1' と '0' を書き込んでいたコードをクリーンアップしなければなりませんでした。それは楽しいデバッグの話ではありませんでした。
幸いなことに、sqliteにはstrictテーブルの概念があり、これによりsqliteは間違った型が列に挿入されたときに型エラーを生成します。
create table music (
id integer primary key,
name text,
duration_sec integer
) strict;
insert into music (name, duration_sec) values ('The Way of All Flesh', 'Way too long, I mean come on');
-- ランタイムエラー: cannot store TEXT value in INTEGER column music.duration_sec (19)
残念ながら、すべてのテーブルをグローバルにstrictにするためのプラグマはありません。そのため、すべてのテーブルに手動でstrictタグを追加することを覚えておく必要があります。
strictテーブルに反対する議論がいくつかありますが、ここで取り上げたいと思います。sqliteの作者は、「柔軟な型付け」を好むことについて書いています。個人的には、これは非常に奇妙な文章だと思います。整数列にblobを挿入することがいつ役立つのかを示す例は提供されていません。提供されているのは、あらゆる型の値を格納できる列を持つことが時々役立つ理由を示す例だけです。strictテーブルにはそのための解決策があります。それはANYデータ型と呼ばれます。あらゆる値を許可する列を作成することは依然として可能ですが、それを行うには明示的である必要があります。
lobste.rs のユーザー 'zie' によって、はるかに優れた議論が提供されています。ご存知の通り、sqliteのstrictテーブルは型を強制するだけではありません。また、型指定子の解析方法のルールも変更します。非strictなsqliteテーブルは、列の型を決定するために次のルールを使用します(sqliteのドキュメントより):
列の宣言された型に "INT" という文字列が含まれている場合、INTEGERアフィニティが割り当てられます。
列の宣言された型に "CHAR"、"CLOB"、または "TEXT" という文字列が含まれている場合、その列はTEXTアフィニティを持ちます。型VARCHARには "CHAR" という文字列が含まれており、したがってTEXTアフィニティが割り当てられることに注意してください。
列の宣言された型に "BLOB" という文字列が含まれている場合、または型が指定されていない場合、その列はBLOBアフィニティを持ちます。
列の宣言された型に "REAL"、"FLOA"、または "DOUB" という文字列が含まれている場合、その列はREALアフィニティを持ちます。
それ以外の場合、アフィニティはNUMERICです。
このルールとsqliteの緩い型付けの組み合わせの結果として、DATETIMEやKEY_VALUE_SETやCOLORのような型名を列に与えることができ、カスタム型の列を自動的にシリアライズおよびデシリアライズするデータベースコネクタ/ラッパーを持つことができます。そして、他に何もなければ、