私が歌川です

@utgwkk が書いている

SQLでSQLを組み立てる

趣味で作っているアプリケーションについて、データマイグレーションを行いたくなった。ちゃんとやるなら、マイグレーション用のスクリプトを書いて、メンテナンスモードにして、スクリプトを適用する……みたいな手順を踏むと思うけど、趣味プロダクトなので高速に済ませたい。SQLでSQLを組み立てればワンタイムスクリプトを書かなくてもよいのではないか、という考えに思い至ったのでメモする。

以下はSQLiteでの事例だけど、他のDBMSでも似たようなことはできると思う。

組み立て方

文字列連結

SQLiteの文字列連結は || 演算子で行う。

sqlite> SELECT 'a' || 'b';
ab

NULLと文字列を連結するとNULLになり、ターミナル上では空行が出力される。 typeof は値の型を返す関数である。空文字列ではなくNULLが出力されていることを確認した。

sqlite> SELECT 'aaa' || NULL;

sqlite> SELECT typeof('aaa' || NULL);
null

文字列のエスケープ

文字列連結でSQLを組み立てると、当然SQLインジェクションのリスクがある。 user テーブルに name カラムがあって、たとえば ' OR 1=1 -- という文字列が格納されていると考える。以下の例ではすでにSQLインジェクションに成功している。

sqlite> SELECT "UPDATE users SET some_flag = 1 WHERE name = '" || "' OR 1=1 --" || "'";
UPDATE users SET some_flag = 1 WHERE name = '' OR 1=1 --'

SQLiteには quote という関数があり、与えられた文字列をクォートして、さらにエスケープも行ってくれる。

sqlite> SELECT "UPDATE users SET some_flag = 1 WHERE name = " || quote("' OR 1=1 --");
UPDATE users SET some_flag = 1 WHERE name = ''' OR 1=1 --'

ちなみにシングルクォートのエスケープは '' である。

sqlite> SELECT 'a''b';
a'b

ここまでの内容で、単純なUPDATE文ぐらいなら書けるようになったと思う。

WHERE IN を書きたい

ところで、こういうSQLを生成したいときはどうしたらよいのか? IN (...) の中には複数の値が入るが、果たしてSQLで書けるのか。

UPDATE users SET some_flag = 1 WHERE id IN (...)

これは group_concat という関数を使うことで実現できる。

まず、対象となるidをかき集めるSELECT文を用意する。

-- 対象となるidをかき集めるSELECT文
SELECT id FROM users WHERE some_condition;

idを group_concat(id) に置換すると、INクエリに突っ込めそうな文字列ができる。

-- 対象となるidをかき集めるSELECT文
sqlite> SELECT group_concat(id) FROM users WHERE some_condition;
2,4,6,8,10,12,14,16,18,20

あとはうまく文字列連結してやればよい。

sqlite> SELECT 'UPDATE users SET some_flag = 1 WHERE id IN (' || group_concat(id) || ')' FROM users WHERE some_condition;
UPDATE users SET some_flag = 1 WHERE id IN (2,4,6,8,10,12,14,16,18,20)

万が一、対象となるidが存在しない場合は group_concat(id) はNULLを返す。従って文字列連結した結果もNULLとなり、空行が出力される。 UPDATE users SET ... WHERE id IN () のようなクエリを実行してしまいエラー、ということにはならないようだった。そもそもSQLiteの場合はエラーにならないが……*1

WHERE IN の中でUPDATEする対象のidをSELECTするのとの違い

SQLを生成する場合は、具体的にどういう行がUPDATEされるのかをクエリやコメントに出力できる。SELECT文だけで何行UPDATEされるかはデータに対する知識がないとすぐには計算できないけど、クエリに直接idが書いてあったら、なんか多すぎておかしい、全くUPDATEされないじゃん、みたいなことに気づけそう。

UPDATE users SET some_flag = 1 WHERE id IN (...) -- ()の中を見たら、UPDATEされたusers.idが分かる

たとえばこういう感じにしたら、対象となるユーザー名も分かってお得。UPDATE文を生成するなら、UPDATEする前の値もメモしておくと後々調査したくなったときに便利かもしれない。

sqlite> SELECT 'UPDATE users SET some_flag = 1 WHERE id = ' || id || '; -- ' || name FROM users;
UPDATE users SET some_flag = 1 WHERE id = 2; -- john_doe
...

まとめ

SQLでSQLを組み立てることでデータマイグレーションを行えそう。生成したSQLをどこかにメモしておけばあとで見返すことができる。

補足 (組み立てたSQLをどう実行するのか)

SQLiteには与えられた文字列をSQLだと思って実行する eval 的な関数はない (当然標準SQLにもないと思う) ので、出力したSQLをコピペして改めて実行することになる。

標準入力から流し込んでもよいし、先頭に BEGIN; を足してからトランザクションを張りつつペーストして実行してもよい。

参考

SQLiteのドキュメントが好きで、よく読んでいる。