Railsにてモデルの関連付けを行おうとしたとき、色々と機能があることに気づきました。
そこで、まずはモデルの関連付けの基本およびNOT NULL制約まわりをさわってみたため、メモを残します。
目次
環境
環境構築
rails new します。
% bundle exec rails new rails_association_sample --minimal --skip-bundle
次に、今回は rbenv-gemsets
を使って、グローバルなRubyにgemを入れることなく RubyMine の Ruby SDK として扱えるようにしています。
そのため、 .rbenv-gemsets
ファイルを用意します。この状態でRubyMineを起動すると、rbenv gemsets が認識されています。
% echo > .rbenv-gemsets rails_association_sample
また、モデルにスキーマの情報があったほうが分かりやすいため、 annotate
をGemfileに追加します。
ctran/annotate_models: Annotate Rails classes with schema and routes info
group :development do gem "annotate" end
必要なgemの記載が終わったため、インストールします。
% bundle install
インストール後、 annotate
の準備をします。
% bin/rails g annotate:install create lib/tasks/auto_annotate_models.rake
関連付けの作成
準備
今回の関連付けのシナリオは
- 最初に、
Food
とCultivar
を用意- お互い関連付けなし
- 次に、
Fruit
を用意し、Food > Fruit > Cultivar
となるような関連付けを作る
とします。
関連付けの全体はこんな感じです。
まずは準備として、2つのマイグレーションファイルやモデルを生成しておきます。
# Food分を生成 % bin/rails g model Food name:string # Cultivar分を生成 % bin/rails g model Cultivar name:string
テーブル生成時に references の引数として単数形で親を指定する
続いて、 Fruit
と Food
の間で関連付けがなされるよう、 Fruit
モデルを作成します。
Railsガイドには
belongs_to関連付けで指定するモデル名は必ず「単数形」にしなければなりません。上記の例で、Bookモデルのauthor関連付けを複数形(authors)にしてからBook.create(authors: @author)でインスタンスを作成しようとすると、uninitialized constant Book::Authorsエラーが発生します。Railsは、関連付けの名前から自動的にモデルのクラス名を推測します。従って、関連付け名が誤って複数形になってしまっていると、そこから推測されるクラス名も誤った形の複数形になってしまいます。
と書かれています。
そこで、
- マイグレーション
- モデル
の2つに対して単数形で親を指定します。
今回はモデルのジェネレータを使い、マイグレーションとモデルの両方を一括で設定します。
% bin/rails g model Fruit name:string food:references
生成されたマイグレーションファイルは以下のとおりです。
class CreateFruits < ActiveRecord::Migration[7.0] def change create_table :fruits do |t| t.string :name t.references :food, null: false, foreign_key: true t.timestamps end end end
生成されたモデルは以下の通りです。
class Fruit < ApplicationRecord belongs_to :food end
Railsコンソールで動作することを確認します。
>> food = Food.create(name: '果物') ... >> fruit = Fruit.create(name: 'りんご', food: food) TRANSACTION (0.0ms) begin transaction Fruit Create (4.8ms) INSERT INTO "fruits" ("name", "food_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["name", "りんご"], ["food_id", 1], ["created_at", "2022-04-24 11:01:47.504418"], ["updated_at", "2022-04-24 11:01:47.504418"]] TRANSACTION (1.3ms) commit transaction => #<Fruit:0x000000010ccda7a0 id: 1, name: "りんご", food_id: 1, created_at: Sun, 24 Apr 2022 11:01:47.504418000 UTC +00:00, updated_at: Sun, 24 Apr 2022 11:01:47.504418000 UTC +00:00>
FruitからFoodをたどるようなデータ取得も行えます。
f = Fruit.find(1).food Fruit Load (0.7ms) SELECT "fruits".* FROM "fruits" WHERE "fruits"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] Food Load (0.8ms) SELECT "foods".* FROM "foods" WHERE "foods"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] => #<Food:0x000000010f905a28 id: 1, name: "果物", created_at: Sun, 24 Apr 2022 11:01:31.836138000 UTC +00:00, updated_at: Sun, 24 Apr 2022 11:01:31.836138000 UTC +00:00>
既存のモデルに関連付けを追加
ここまでで
- Food
- Fruit
- Cultivar
の3モデルができました。
ただ、 Food - Fruit
間に関連付けはある一方、 Fruit - Cultivar
間には関連付けがありません。
Railsガイドを見ると
- belongs_to関連付けを使う場合は、外部キーを作成する必要があります。
とありました。
そこで、Fruit - Cultivar
間に関連付けを行うため、子モデルに対して
- 外部キーを追加
belongs_to
を追加
を行います。
子モデルに外部キーを追加
まず、子である Cultivar
モデルから、親である Fruit
モデルへの外部キーを設定します。
マイグレーションファイルを生成します。
% bin/rails g migration AddFruitToCultivars fruit:references
中身はこんな感じです。
class AddFruitToCultivars < ActiveRecord::Migration[7.0] def change add_reference :cultivars, :fruit, null: false, foreign_key: true end end
良さそうですので、マイグレーションを実行します。
% bin/rails db:migrate
この時点でDB上は外部キーの設定はできているものの、データを登録しようとしてもエラーになります。
>> fruit = Fruit.find(1) Fruit Load (0.3ms) SELECT "fruits".* FROM "fruits" WHERE "fruits"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] => #<Fruit:0x000000010f9e6b18 id: 1, name: "りんご", food_id: 1, created_at: Sun, 24 Apr 2022 11:01:47.504418000 UTC +00:00, updated_at: Sun, 24 Apr 2022 11:01:47.504418000 UTC +00:00> >> cultivar = Cultivar.create(name: 'ふじ', fruit: fruit) /path/to/rails_association_sample/gems/activemodel-7.0.2.3/lib/active_model/attribute.rb:211:in `with_value_from_database': can't write unknown attribute `fruit_id` (ActiveModel::MissingAttributeError)
子モデルに belongs_to を追加
次に、子である Cultivar
モデルにて、親である Fruit
モデルに対する belongs_to
を追加します。
class Cultivar < ApplicationRecord belongs_to :fruit end
再度Railsコンソールで動作確認したところ、エラーは発生しなくなりました。
>> fruit = Fruit.find(1) (1.1ms) SELECT sqlite_version(*) Fruit Load (0.2ms) SELECT "fruits".* FROM "fruits" WHERE "fruits"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] => #<Fruit:0x000000010772a210 id: 1, name: "りんご", food_id: 1, created_at: Sun, 24 Apr 2022 11:01:47.504418000 UTC +00:00, updated_at: Sun, 24 Apr 2022 11:01:47.504418000 UTC +00:00> >> cultivar = Cultivar.create(name: 'ふじ', fruit: fruit) TRANSACTION (0.1ms) begin transaction Cultivar Create (1.2ms) INSERT INTO "cultivars" ("name", "created_at", "updated_at", "fruit_id") VALUES (?, ?, ?, ?) [["name", "ふじ"], ["created_at", "2022-04-24 12:01:44.813548"], ["updated_at", "2022-04-24 12:01:44.813548"], ["fruit_id", 1]] TRANSACTION (1.9ms) commit transaction => #<Cultivar:0x000000010779b708 id: 1, name: "ふじ", created_at: Sun, 24 Apr 2022 12:01:44.813548000 UTC +00:00, updated_at: Sun, 24 Apr 2022 12:01:44.813548000 UTC +00:00, fruit_id: 1>
NOT NULL制約のない関連付けを作成
ここまで見てきた関連付けの場合、デフォルトでは NOT NULL 制約が設定されています。
そこで、次は NULL を設定できるような関連付けを行ってみます。
新規作成するときに、NOT NULL 制約を外す
新規作成時の作業を確認するため、新しいモデルとして UserGroup
と Member
の2つのモデルを用意します。
- UserGroup : Member = 1 : n
- Memberの外部キーは NULL を許可
とします。
ER図的にはこんな感じです。
ジェネレータでモデルとマイグレーションファイルを作成
# UserGroup分 % bin/rails g model UserGroup name:string # Member分 % bin/rails g model Member name:string user_group:references
マイグレーションファイルを修正し、NOT NULL制約を外す
ジェネレータで生成した Member
のマイグレーションファイルを開きます。 NOT NULL 制約を外すため、 null: true
とします。
class CreateMembers < ActiveRecord::Migration[7.0] def change create_table :members do |t| t.string :name t.references :user_group, null: true, foreign_key: true t.timestamps end end end
マイグレーションを実行します。
% bin/rails db:migrate
モデルの belongs_to の引数を修正
この時点でDB上のテーブルの外部キーは NOT NULL 制約が外れています。
ただ、この状態で NULL なデータを登録しようとしても、モデルのバリデーションでエラーになります。
>> m = Member.create!(name: 'Foo', user_group: nil) (1.9ms) SELECT sqlite_version(*) /path/to/rails_association_sample/gems/activerecord-7.0.2.3/lib/active_record/validations.rb:80:in `raise_validation_error': Validation failed: User group must exist (ActiveRecord::RecordInvalid)
そのため、モデルの belongs_to
を変更し、NULL設定も可能とします。
class Member < ApplicationRecord belongs_to :user_group, required: false end
再度動作確認すると、NULLも設定できるようになっています。
>> m = Member.create!(name: 'Foo', user_group: nil) (1.2ms) SELECT sqlite_version(*) TRANSACTION (0.1ms) begin transaction Member Create (0.4ms) INSERT INTO "members" ("name", "user_group_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["name", "Foo"], ["user_group_id", nil], ["created_at", "2022-04-24 12:26:04.383204"], ["updated_at", "2022-04-24 12:26:04.383204"]] TRANSACTION (0.9ms) commit transaction => #<Member:0x00000001099a0970 id: 2, name: "Foo", user_group_id: nil, created_at: Sun, 24 Apr 2022 12:26:04.383204000 UTC +00:00, updated_at: Sun, 24 Apr 2022 12:26:04.383204000 UTC +00:00>
既存のモデルに対し、NOT NULL制約を外す
次に、既存の Cultivar
にある外部キーを nullable にしてみます。
コンソールでNULLにできないことを確認します。
>> c = Cultivar.create!(name: 'シナノゴールド', fruit: nil) /path/to/rails_association_sample/gems/activerecord-7.0.2.3/lib/active_record/validations.rb:80:in `raise_validation_error': Validation failed: Fruit must exist (ActiveRecord::RecordInvalid)
マイグレーションファイルで change_column_null を使う
マイグレーションファイルを独自に作ります。
% bin/rails g migration ChangeForeignKeyToCultivars
空のマイグレーションファイルが生成されるため、 change_column_null
を使って、NULL制約を変更します。
https://api.rubyonrails.org/v7.0/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-change_column_null
class ChangeForeignKeyToCultivars < ActiveRecord::Migration[7.0] def change change_column_null :cultivars, :fruit_id, true end end
マイグレーションします。
% bin/rails db:migrate
モデルの belongs_to の引数を修正
新規作成と同様、モデルの引数に required: false
を追加します。
class Cultivar < ApplicationRecord belongs_to :fruit, required: false end
再度動作確認すると、外部キーにNULLを設定しても登録できました。
>> c = Cultivar.create!(name: 'シナノゴールド', fruit: nil) (1.0ms) SELECT sqlite_version(*) TRANSACTION (0.1ms) begin transaction Cultivar Create (0.4ms) INSERT INTO "cultivars" ("name", "created_at", "updated_at", "fruit_id") VALUES (?, ?, ?, ?) [["name", "シナノゴールド"], ["created_at", "2022-04-24 13:41:33.255011"], ["updated_at", "2022-04-24 13:41:33.255011"], ["fruit_id", nil]] TRANSACTION (1.0ms) commit transaction => #<Cultivar:0x000000010abf0878 id: 2, name: "シナノゴールド", created_at: Sun, 24 Apr 2022 13:41:33.255011000 UTC +00:00, updated_at: Sun, 24 Apr 2022 13:41:33.255011000 UTC +00:00, fruit_id: nil>
関連付けに失敗する方法を確認
ここまでは関連付けに成功する方法を書いてきました。
そこで、次はうまくいかない関連付けのやり方を書いておきます。
子モデルにて、親モデルを複数形で指定する
Railsガイドには
belongs_to関連付けで指定するモデル名は必ず「単数形」にしなければなりません。上記の例で、Bookモデルのauthor関連付けを複数形(authors)にしてからBook.create(authors: @author)でインスタンスを作成しようとすると、uninitialized constant Book::Authorsエラーが発生します。Railsは、関連付けの名前から自動的にモデルのクラス名を推測します。従って、関連付け名が誤って複数形になってしまっていると、そこから推測されるクラス名も誤った形の複数形になってしまいます。
との記載があります。
そこで実際に試してみます。 Food
と Cultivar
だけがある状態からはじめます。
# Food分を生成 % bin/rails g model Food name:string # Cultivar分を生成 % bin/rails g model Cultivar name:string
次に、 Fruit
のモデル・マイグレーションファイルを生成します。
% bin/rails g model Fruit name:string foods:references
マイグレーションを実行しても、特にエラーとはなりません。
% bin/rails db:migrate
生成されたモデルのスキーマを見ても、特に問題なさそうではあります。
# Table name: fruits # # id :integer not null, primary key # ... # foods_id :integer not null # ... # Foreign Keys # # foods_id (foods_id => foods.id)
しかし、Railsコンソールで動作確認すると、Railsガイドにあるようなエラーとなりました。やはり references
に指定する親クラスは単数形が良さそうです。
>> food = Food.create(name: '果物') ... >> fruit = Fruit.create(name: 'りんご', foods: food) ... Rails couldn't find a valid model for Foods association. Please provide the :class_name option on the association declaration. If :class_name is already provided, make sure it's an ActiveRecord::Base subclass. (NameError) ... uninitialized constant Fruit::Foods (NameError)
中間モデルにて一括で関連付けしてみる
Fruit
を生成する際、 Food - Fruit
と Fruit - Cultivar
の2つの関連付けができたら便利かもしれないと思い、試してみることにしました。
まずは Food
と Cultivar
を生成します。
# Food分を生成 % bin/rails g model Food name:string # Cultivar分を生成 % bin/rails g model Cultivar name:string
次に以下のような感じで関連付けをしてみます。
% bin/rails g model Fruit name:string food:references cultivars:references
マイグレーションしてみます。
% bin/rails db:migrate
しかし、モデルのスキーマを見ると、Fruit に cultivars_id
が追加されてしまい、cultivarに向けて外部キーが設定されてしまっているようでした。
残念ながら一括ではできませんでした。
# == Schema Information # # Table name: fruits # # id :integer not null, primary key # ... # cultivars_id :integer not null # food_id :integer not null # ... # Foreign Keys # # cultivars_id (cultivars_id => cultivars.id) # food_id (food_id => foods.id)
ソースコード
Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_association-sample