TechBox

スタートアップで働くRails&機械学習エンジニアのブログ。時々GT-Rと旅行。

Railsでreferencesを使用した外部キーに、同時にUnique属性を設定する

Railsで新しいモデル(テーブル)の作成で外部キー(外部参照)を設定する時に、
同時にユニーク属性を設定していく手順を記載します。
余り使う機会はないかもしれませんが、今回は以下の例を使っていきます。

(例)Userが複数のOrganizationに所属できる多対多の関係で、そのうちの一のOrganizationをデフォルトとして保持するDefaultOrganizationテーブルを作成する場合。
   以下がER図です。

f:id:daisuke-jp:20160923103021p:plain

試行錯誤して結果を得たので、経過も踏まえて記述します。 解答に書いている事がやり方なので、手早く正解が欲しい方はそちらをご覧下さい。

なぜ必要なのか

使用しているDBにもよりますが、先に出した例であれば、中間テーブルにデフォルトフラグ的な項目があれば良いと思います。
しかし、それだとDefaultOrganizationはユーザーにつき1つだけというユニーク属性(一意制約)を設定できない場合があります。
アプリ側で制限したら良いよってポリシーの場合は特段ここまで考える必要はありませんが、
DBのリレーションまでキチンと設計しておきたい場合、PostgreSQLやMySQLであれば部分インデックスで可能ですが、OracleやMySQLだと部分インデックスが使えないって制限があります。 そういった場合に使えるかと思います。

なお、中間テーブルと、中間テーブルのフラグについては、こちらに書きました。

www.techbox.jp

普通に外部参照だけをする場合

マイグレーションファイルを作成します。

rails g migration CreateDefaultOrganization user:references relation:references

※この時に、userとorganizationにidを付与しません。
 referencesにした場合、勝手に補完されますので、モデル名だけで良いです。

すると、以下の様なマイグレーションファイルが作成されます。

class DefaultOrganization < ActiveRecord::Migration
  def change
    create_table :default_organizations do |t|
      t.references :user, index: true, foreign_key: true
      t.references :relation, index: true, foreign_key: true
      t.timestamps null: false
    end
  end
end

これをrake db:migrateすれば中間テーブルの完成です。
非常に簡単ですね。railsの素早さを実感できます。

この場合、DBを見て頂くと分かりますが、
foreign_keyは有効になっており、インデックスも作成されています。

index作成と同じ要領でuniqueしてみる

そこで、indexを作成する要領でunique属性を付けてみます。

1ファイル内で指定

class DefaultOrganization < ActiveRecord::Migration
  def change
    create_table :default_organizations do |t|
      t.references :user, index: true, foreign_key: true, unique: true
      t.references :relation, index: true, foreign_key: true
      t.timestamps null: false
    end
  end
end

エラーは出ませんが、前回と同じ設定となってユニークは設定されません。
後にわかりますが、ここでforeign_keyとuniqueの同時指定をする場合はこの記述方法だと効果がありません。

2ファイル内で別々に指定

同時していするのがダメなのかと思い、一端default_organizationsテーブルを作成し、
そこにインデックスを付与して、外部参照キーを設定してみます。

class DefaultOrganization < ActiveRecord::Migration
  def change
    create_table :default_organizations do |t|
      t.references :user
      t.references :relation
      t.timestamps null: false
    end
  end
end
class AddIndexToDefaultOrganization < ActiveRecord::Migration
  def up
    add_index :default_organizations, :user_id, unique: true
    add_index :default_organizations, :relation_id
  end
  def change
    add_foreign_key :default_organizations, :user
    add_foreign_key :default_organizations, :relation
  end
end

この場合も1ファイルで行った時と同様で、テーブル、外部参照、インデックスは作成されますが、 ユニーク属性が付与されません。

インデックス付与だけを別に実行

今度は外部参照までをテーブル作成時に行い、インデックスを別で付与していきます。

class DefaultOrganization < ActiveRecord::Migration
  def change
    create_table :default_organizations do |t|
      t.references :user, foreign_key: true
      t.references :relation, index: true, foreign_key: true
      t.timestamps null: false
    end
    add_index :default_organizations, :user_id, unique: true
  end
end

この方法だと外部参照かつユニーク属性のインデックスが付与されました!
しかし、rake db:rollbackでロールバックをしようとするとエラーが発生し、ロールバックができません。
これは、外部キー指定のカラムのインデックスを先に削除しようとしているから発生しているからで、 マイグレーションファイルからadd_index :default_organizations, :user_id, unique: true を削除すると実行されます。
これは、DB側の仕様ですね。 一応設定はできますが、このやり方だとロールバックが発生した際に何で?ってなりますし、 凄く気持ち悪いですね。

解答

紆余曲折しましたが、こちらが解答となります。 マイグレーションファイル1つでできてしまいます。

class DefaultOrganization < ActiveRecord::Migration
  def change
    create_table :default_organizations do |t|
      t.references :user, index: { unique: true }, foreign_key: true
      t.references :relation, index: true, foreign_key: true
      t.timestamps null: false
    end
  end
end

indexの指定の仕方が特殊で、trueではなく { unique: true } となります。
これだけです。
これをマイグレートすると、default_organizationsテーブルが作成され、 user_idとrelation_idに外部キー属性が、user_idはユニークとなります。