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のソースを読んで確認したいところです。