Railsのモデルに複合一意制約を定義する方法、そしてうっかりハマる

Rails 3.0.4で複合一意制約を定義する方法は二通りあるようです。


1.ActiveRecordでモデルに一意制約を定義する
2.RDBMSで一意索引を作る


通常は両方実装するものと思われます。

次のようなモデルで確認します。

class CreateMultiples < ActiveRecord::Migration
  def self.up
    create_table :multiples do |t| 
      t.string :key1
      t.string :key2
      t.string :key3
      t.string :value

      t.timestamps
    end 
  end 

  def self.down
    drop_table :multiples
  end 
end

まず1.の場合ですが、Stack Overflowにありました。

In Rails 2, I would have written:

validates_uniqueness_of :zipcode, :scope => :recorded_at
In Rails 3:

validates :zipcode, :uniqueness => {:scope => :recorded_at}

rails 3 validation on uniqueness on multiple attributes - Stack Overflow

ハッシュのキーに:scopeで2番目の属性を指定します。この例は、属性が2つの場合で、3つの場合が不明だったので、試してみたところ、次のコードでOKでした。

class Multiple < ActiveRecord::Base
  validates :key1, :uniqueness => { :scope => [:key2, :key3] }
end

:scopeのvalueをシンボルの配列にします。
注意として、今、DBが次のような状態のところへ、

キー重複したデータを入力すると、

エラーで色が変わるのが複合一意制約の先頭の属性のみになるので、アプリケーション利用者が戸惑う可能性があります。

また、この場合、同一データが既にあるかを確認するので、一度SELECT文が発行されています。このパフォーマンスを上げるためにDB上でも索引を作成しておくのが通常の実装と考えられます。この場合、Rubyでキー重複を確認しているはずなので、誤って大量のレコードが取得されると大変かもしれません。

  Multiple Load (0.7ms)  SELECT "multiples"."id" FROM "multiples" WHERE 
"multiples"."key2" = 'A' AND "multiples"."key3" = 'B' AND ("multiples"."key1" = 'A') LIMIT 1
Rendered multiples/_form.html.erb (6.2ms)
Rendered multiples/new.html.erb within layouts/application (10.2ms)
Completed 200 OK in 258ms (Views: 14.9ms | ActiveRecord: 0.9ms)
^

次に、2.の場合はマイグレーションファイルに次のように記述します。

class AddIndexToMultiples < ActiveRecord::Migration
  def self.up
    add_index :multiples, [:key1, :key2, :key3], :unique => true
  end 

  def self.down
    remove_index :multiples, [:key1, :key2, :key3]
  end 
end

単一属性に一意制約を定義する場合はadd_indexの第2引数が属性のシンボルですが、複合一意制約の場合はシンボルの配列で指定しますRDBMSに一意制約を定義しているので、一意制約に反するデータをcreateするとSQLのエラーが返ってアプリケーションが落ちるので、独自にエラーハンドリングを実装する必要があります。scaffoldを作って試すと以下のような画面になります。

サーバのログを見ると、ActiveRecordが使用しているRDBMSのエラーを受けているようです。

SQLite3::ConstraintException: columns key1, key2, key3 are not unique: 
INSERT INTO "multiples" ("key1", "key2", "key3", "value", "created_at", "updated_at")
 VALUES ('A', 'A', 'B', '3', '2011-05-07 14:44:58.152136', '2011-05-07 14:44:58.152136')
Completed   in 180ms

ActiveRecord::RecordNotUnique (SQLite3::ConstraintException: columns 
key1, key2, key3 are not unique: INSERT INTO "multiples" ("key1", "key2", "key3", "value", "created_at", "updated_at") VALUES 
('A', 'A', 'B', '3', '2011-05-07 14:44:58.152136', '2011-05-07 14:44:58.152136')):
  app/controllers/multiples_controller.rb:46:in `block in create'

最後に、ハマった点を1つ。1.場合は、モデルに":uniqueness"と記述しますが、2.の場合はマイグレーションに":unique"と記述します。これを混同して、2.のマイグレーションに":uniqueness"と書いてしまった場合、rake db:migrateが正常終了しますが、作成された索引に一意制約が付かないようです。

$ rake db:migrate
(in /Users/...)
==  AddIndexToMultiples: migrating ============================================
-- add_index(:multiples, [:key1, :key2, :key3], {:uniqueness=>true})
   -> 0.0012s
==  AddIndexToMultiples: migrated (0.0013s) ===================================

いつかadd_indexのソースを読んで確認したいところです。