Railsで、関連付けの基本や関連付けにおけるNOT NULL制約まわりをさわってみた

Railsにてモデルの関連付けを行おうとしたとき、色々と機能があることに気づきました。

 
そこで、まずはモデルの関連付けの基本およびNOT NULL制約まわりをさわってみたため、メモを残します。

 
目次

 

環境

  • Rails 7.0.2.3
  • RubyMine 2021.3.3
  • rbenv-gemset 0.5.10
    • RubyMineのRuby SDK にて、プロジェクト単位で gem を管理するため

 

環境構築

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

 

関連付けの作成

準備

今回の関連付けのシナリオは

  • 最初に、 FoodCultivar を用意
    • お互い関連付けなし
  • 次に、 Fruit を用意し、 Food > Fruit > Cultivar となるような関連付けを作る

とします。

関連付けの全体はこんな感じです。

 
まずは準備として、2つのマイグレーションファイルやモデルを生成しておきます。

# Food分を生成
% bin/rails g model Food name:string

# Cultivar分を生成
% bin/rails g model Cultivar name:string

 

テーブル生成時に references の引数として単数形で親を指定する

続いて、 FruitFood の間で関連付けがなされるよう、 Fruit モデルを作成します。

Railsガイドには

belongs_to関連付けで指定するモデル名は必ず「単数形」にしなければなりません。上記の例で、Bookモデルのauthor関連付けを複数形(authors)にしてからBook.create(authors: @author)でインスタンスを作成しようとすると、uninitialized constant Book::Authorsエラーが発生します。Railsは、関連付けの名前から自動的にモデルのクラス名を推測します。従って、関連付け名が誤って複数形になってしまっていると、そこから推測されるクラス名も誤った形の複数形になってしまいます。

2.1 belongs_to関連付け | Active Record の関連付け - 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ガイドを見ると

  1. belongs_to関連付けを使う場合は、外部キーを作成する必要があります。

3.3 スキーマの更新 | Active Record の関連付け - Railsガイド

とありました。

 
そこで、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 制約を外す

新規作成時の作業を確認するため、新しいモデルとして UserGroupMember の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は、関連付けの名前から自動的にモデルのクラス名を推測します。従って、関連付け名が誤って複数形になってしまっていると、そこから推測されるクラス名も誤った形の複数形になってしまいます。

2.1 belongs_to 関連付け | Active Record の関連付け - Railsガイド

との記載があります。

 
そこで実際に試してみます。 FoodCultivar だけがある状態からはじめます。

# 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 - FruitFruit - Cultivar の2つの関連付けができたら便利かもしれないと思い、試してみることにしました。

まずは FoodCultivar を生成します。

# 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