はじめに
もう10年以上前にはなるけど、当時PostgreSQLのdeferred constraintを知らなくて、プログラムのロジックで対応したというお話。 それじゃあどう書けばよかったのかというところを整理する。
そもそも
PostgreSQLにはdeferred constraint(遅延制約)というのがあり(MySQL/MariaDBにはない)、各制約は3つのうちの1つが特性として生成時に設定される。
Upon creation, a constraint is given one of three characteristics: DEFERRABLE INITIALLY DEFERRED, DEFERRABLE INITIALLY IMMEDIATE, or NOT DEFERRABLE.
PostgreSQL: Documentation: 18: SET CONSTRAINTS
IMMEDIATEだと、制約はトランザクションの終了を待たずに即座にチェックされ、DEFERREDだと、制約のチェックはトランザクションの終了まで遅延させられる。
やったこと(現状)
例えば、「表示順」というカラムをテーブルに持たせて、そのカラムで常にソートして結果を返すようなケースを想定する。 その場合、行を途中に追加する場合の「挿入」、行が削除される場合の「削除」、そして行と行を入れ替える「入れ替え」という操作が必要になる。
SQLで書き下すと以下のような感じ。
DROP TABLE test1; CREATE TABLE test1(id BIGSERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, show_order BIGINT NOT NULL UNIQUE); INSERT INTO test1(name, show_order) VALUES('Taro', 1); INSERT INTO test1(name, show_order) VALUES('Jiro', 2); INSERT INTO test1(name, show_order) VALUES('Saburo', 3); SELECT * FROM test1 ORDER BY show_order; -- 挿入 START TRANSACTION; -- 一括で更新しようとしても、1行ずつ更新していく過程で重複が発生してエラーになる -- UPDATE test1 SET show_order = show_order + 1 WHERE show_order >= 2; UPDATE test1 SET show_order = 4 WHERE show_order = 3; UPDATE test1 SET show_order = 3 WHERE show_order = 2; INSERT INTO test1(name, show_order) values('Ichiro', 2); COMMIT; SELECT * FROM test1 ORDER BY show_order; -- 削除 START TRANSACTION; DELETE FROM test1 WHERE name = 'Ichiro'; -- 一括で更新しようとしても、1行ずつ更新していく過程で重複が発生してエラーになる -- UPDATE test1 SET show_order = show_order - 1 WHERE show_order > 2; UPDATE test1 SET show_order = 2 WHERE show_order = 3; UPDATE test1 SET show_order = 3 WHERE show_order = 4; COMMIT; SELECT * FROM test1 ORDER BY show_order; -- 入れ替え START TRANSACTION; -- 「3を2に、2を3に」ということをしたいが、3を2にした時点で2が重複する -- UPDATE test1 SET show_order = 2 WHERE show_order = 3; -- UPDATE test1 SET show_order = 3 WHERE show_order = 2; -- これも一緒で、DBMS的には「一括」はありえないので、どこかで重複が発生する -- UPDATE test1 SET show_order = CASE -- WHEN show_order = 3 THEN 2 -- WHEN show_order = 2 THEN 3 -- END WHERE show_order IN (2, 3); -- 絶対にありえない値(例:-1)を使って3段階で更新する UPDATE test1 SET show_order = -1 WHERE show_order = 3; UPDATE test1 SET show_order = 3 WHERE show_order = 2; UPDATE test1 SET show_order = 2 WHERE show_order = -1; COMMIT; SELECT * FROM test1 ORDER BY show_order;
これ、特に「挿入」と「削除」で、データが数個~十数個くらいだったらいいけど、いちいちプログラム側で「更新すべきshow_orderの範囲を取得して、その範囲内でループを回して、重複が発生しないように順番に更新していく」なんてことをやってしまったわけですよ。
この辺が、当時の私の限界だった。
どうすればよかったのか
PostgreSQLが持つdeferred constraintを使うとどう書き換えられるのか、実際にやってみた。
DROP TABLE test2; CREATE TABLE test2(id BIGSERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, show_order BIGINT NOT NULL UNIQUE DEFERRABLE INITIALLY DEFERRED); INSERT INTO test2(name, show_order) VALUES('Taro', 1); INSERT INTO test2(name, show_order) VALUES('Jiro', 2); INSERT INTO test2(name, show_order) VALUES('Saburo', 3); SELECT * FROM test2 ORDER BY show_order; -- 挿入 START TRANSACTION; UPDATE test2 SET show_order = show_order + 1 WHERE show_order >= 2; INSERT INTO test2(name, show_order) VALUES('Ichiro', 2); COMMIT; SELECT * FROM test2 ORDER BY show_order; -- 削除 START TRANSACTION; DELETE FROM test2 WHERE name = 'Ichiro'; UPDATE test2 SET show_order = show_order - 1 WHERE show_order > 2; COMMIT; SELECT * FROM test2 ORDER BY show_order; -- 入れ替え START TRANSACTION; UPDATE test2 SET show_order = CASE WHEN show_order = 3 THEN 2 WHEN show_order = 2 THEN 3 END WHERE show_order IN (2, 3); COMMIT; SELECT * FROM test2 ORDER BY show_order;
だいぶすっきりした。
ポイントは、show_orderカラムにDEFERRABLE INITIALLY DEFERREDが付いたところ。
「入れ替え」に関しては、フレームワークのクエリビルダーで書こうとするとちょっとめんどくさいので現状の方がいいのかなと思ったり、「絶対にありえない値」は「本当に絶対なのか?」というのがあるのでやっぱり後者なのかと思ったりするけど。
MySQL/MariaDBの場合
先にちょっと触れたように、MySQL/MariaDBにはdeferred constraintがないらしく、どうやって書くことになるのか、というのも合わせて書いてみる。
DROP TABLE IF EXISTS test2; CREATE TABLE test2(id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, show_order BIGINT NOT NULL UNIQUE); INSERT INTO test2(name, show_order) VALUES('Taro', 1); INSERT INTO test2(name, show_order) VALUES('Jiro', 2); INSERT INTO test2(name, show_order) VALUES('Saburo', 3); SELECT * FROM test2 ORDER BY show_order; -- 挿入 START TRANSACTION; UPDATE test2 SET show_order = show_order + 1 WHERE show_order >= 2 ORDER BY show_order DESC; INSERT INTO test2(name, show_order) VALUES('Ichiro', 2); COMMIT; SELECT * FROM test2 ORDER BY show_order; -- 削除 START TRANSACTION; DELETE FROM test2 WHERE name = 'Ichiro'; UPDATE test2 SET show_order = show_order - 1 WHERE show_order > 2 ORDER BY show_order ASC; COMMIT; SELECT * FROM test2 ORDER BY show_order; -- 入れ替え START TRANSACTION; -- 構文的には書けるけど、一瞬だけ重複が発生してエラーになる -- UPDATE test2 SET show_order = CASE -- WHEN show_order = 3 THEN 2 -- WHEN show_order = 2 THEN 3 -- END WHERE show_order IN (2, 3); UPDATE test2 SET show_order = -1 WHERE show_order = 3; UPDATE test2 SET show_order = 3 WHERE show_order = 2; UPDATE test2 SET show_order = 2 WHERE show_order = -1; COMMIT; SELECT * FROM test2 ORDER BY show_order;
MySQL/MariaDBでは、UPDATE文でORDER BY句を指定することができて、更新順を制御できるらしい。それを使って「挿入」と「削除」の場合の問題を解決している。
入れ替えの場合は、PostgreSQLのようにはいかないので、媒介変数的な値を使うしか手段が見つけられなかった。

