Rails でユニーク制約

最近 Rails を使い始めたのですが、気になったことがあったのでメモっときます。

モデルに一意性バリデーションをつけても厳密にはチェックされません。もしかしたら Rails では常識なのかもしれませんが、Rails 初心者なので気になってしまいました。

普通に Rails アプリを作ります。User モデルに login 属性をつけてます。

% rails new hoge -d mysql
% cd hoge
% vi config/database.yml
% rails g scaffold user login
% rake db:create db:migrate

User モデルに一意性バリデーションつけます。

% vi app/models/user.rb
class User < ActiveRecord::Base
  attr_accessible :login
  validates_uniqueness_of :login
end

ユーザーを作ってみます。

% rails c
Loading development environment (Rails 3.2.3)
irb(main):001:0> User.create(login:'hoge')
   (0.1ms)  BEGIN
  User Exists (0.7ms)  SELECT 1 FROM `users` WHERE `users`.`login` = BINARY 'hoge' LIMIT 1
  SQL (0.3ms)  INSERT INTO `users` (`created_at`, `login`, `updated_at`) VALUES ('2012-06-02 08:01:32', 'hoge', '2012-06-02 08:01:32')
   (40.5ms)  COMMIT
=> #<User id: 1, login: "hoge", created_at: "2012-06-02 08:01:32", updated_at: "2012-06-02 08:01:32">

もう一度同じユーザーを作ってみます。

irb(main):002:0> User.create(login:'hoge')
   (0.3ms)  BEGIN
  User Exists (0.6ms)  SELECT 1 FROM `users` WHERE `users`.`login` = BINARY 'hoge' LIMIT 1
   (0.3ms)  ROLLBACK
=> #<User id: nil, login: "hoge", created_at: nil, updated_at: nil>

今度はロールバックされました。

発行されるクエリを見ると、INSERT の前に SELECT で値が既存かどうかチェックしてるのがわかります。

しかし、これだと SELECT と INSERT の間に他のクライアントから同じ値が登録されてしまうと重複登録されてしまいます。

なので本当に一意性を保証したい場合は、データベース側でも一意性制約をつける必要があります。

% vi db/migrate/20120602095609_add_index_to_users.rb
class AddIndexToUsers < ActiveRecord::Migration
  def change
    add_index :users, :login, :unique=>true
  end
end
% rake db:migrate

これでデータベースに重複登録されることはなくなったはずです。

試しにレコードの登録を削除を繰り返してみます。

irb(main):004:0> loop{User.create(login:'hoge'); User.delete_all(login:'hoge')}

これを複数の端末から同時に動かしてしばらくすると、いずれかの端末で次のエラーが発生します。

   (0.1ms)  BEGIN
  User Exists (0.3ms)  SELECT 1 FROM `users` WHERE `users`.`login` = BINARY 'hoge' LIMIT 1
  SQL (78.2ms)  INSERT INTO `users` (`created_at`, `login`, `updated_at`) VALUES ('2012-06-02 08:13:41', 'hoge', '2012-06-02 08:13:41')
Mysql2::Error: Duplicate entry 'hoge' for key 'index_users_on_login': INSERT INTO `users` (`created_at`, `login`, `updated_at`) VALUES ('2012-06-02 08:13:41', 'hoge', '2012-06-02 08:13:41')
   (198.5ms)  ROLLBACK

SELECT でレコードが存在しなかったので INSERT しようとしたら、その間に他のクライアントから登録されてしまって重複エラーになりました。

なお、このエラーはバリデーションのチェックにはならず、そのままアプリがエラーになってしまいます。エラーにせず、一意性のバリデーションのチェックに引っかかったのと同じようにする方法はどうやらないみたいです。ほとんど発生しないから考えないって感じでしょうか。

それにしても、テーブル構造をデータベースから取得して自動的にモデルを作ってくれたりする割には、データベースのユニーク制約をちゃんと扱ってくれないのはイマイチな気がします。