リモート開発メインのソフトウェア開発企業のエンジニアブログです

[Rails] ActiveRecord のスコープはレコード作成時に値をセットしてくれる

久しぶりの投稿です。私は Rails を数年業務で使っているのですが、実は最近タイトルの件を知り、ベテランの Rails ユーザーにとっては初歩的な内容 (機能自体も rdoc に書いてある) なので記事にするか悩みましたが、自己の理解を深めると共に、弊社には Rails (ActiveRecord) の初学者のメンバーもいるのであえて書いておきます。

また、例によって本記事で扱ってるコードを GitHub に公開しているので是非ご覧下さい: https://github.com/issei-m/rails_test_ar_relations

検証したバージョン

Ruby: 3.1.2
Rails: 7.0.3

本記事中の「スコープ」について

本記事ではカナ表記の「スコープ」は ActiveRecord::Relation の事を指します。これは何なのかと言うと、ある種のクエリビルダ機能を持ったモデルのコレクション表現 (0行以上の複数レコードを保持する) の様なものです。.where メソッドでモデルの attribute で絞り込んだり、 exists? による存在確認、 .count による件数取得、 to_ary で現在の絞り込み状態にマッチするレコードを Array に変換する事ができます。ActiveRecord::Base に組み込まれているので、実際にはモデルクラスから使う事になります。

次の様なスキーマ、モデルが定義されているとします:

# schema
create_table "people", force: :cascade do |t|
  t.string "nickname", null: false
  t.integer "sex", limit: 1, null: false
  t.integer "prefecture_id", limit: 1, null: false
end

create_table "prefectures", force: :cascade do |t|
  t.string "name", null: false
end

# models
class Prefecture < ApplicationRecord; end

class Person < ApplicationRecord
  belongs_to :live_in, class_name: :Prefecture, foreign_key: :prefecture_id   
  enum sex: [ :male, :female, :other ]
end    

この様な構成の時、 Person.where(sex: :male) (性別が男性の Person のみを絞り込む) を実行すると返ってくる値が ActiveRecord::Relation (実際にはそれを継承した Person::ActiveRecord_Relation) になります。

さて、次に以下の様な scope を Person モデルに宣言します:

class Person
  # ...  

  scope :male, -> { where(sex: :male) }
end

この場合、 Person.male は Person.where(sex: :male) と同義なので、返り値は ActiveRecord::Relation になります。

この辺りは ActiveRecord の基本的な機能で、普段からよく目にしているかと思います。

ちなみに rails console を使っていると Person.male を評価すると即時にクエリが実行され、 Person の結果がリストで返ってきますが、これは rails console の機能でActiveRecord::Relation の様な Enumerable な値を評価させると、中身を表示する為に .to_a が実行されるからです。普通にアプリ内で使っている場合は、 .each や .map (勿論 .to_a も) などを実行する等してリストが必要になるまでクエリの実行は遅延されます。

ActiveRecord::Relation から新規レコードを作る

通常、 ActiveRecord モデルのレコードを新たに作る場合は、普通にモデルクラスの .new や .create を使うと思いますが、 ActiveRecord::Relation からも行う事ができます。

先程の構成では Person.where(sex: :male).new や Person.male.create の様に実行でき、こうすると予め sex に :male がセットされたレコードが作られます。

males = Person.male

# 男性のレコードが作られている場合は一番先頭の物を、まだ作られていない場合は新規作成して返す.
male = if males.exists?
         males.first
       else
         males.create!(nickname: "Taro", live_in: Prefecture.find(13))
       end

因みにこれは普通に公式の rdoc にも書いてあります。

何が便利か?

既に先ほどのスニペットでも見せましたが、設定したスコープから直接レコードが作れるのでコードの記述量が少なく、文脈的になるので見通しが良くなります。

males = Person.male
males.create!(nickname: "Taro", live_in: Prefecture.find(13))

これは、 Person.create!(nickname: "Taro", live_in: Prefecture.find(13), sex: :male) と同義になります。(Person は、 sex 以外に nicknamelive_in (prefecture_id) が必須項目なので別途設定が必要です)

勘の良い方なら、スコープは連鎖させられる事に気づいたかもしれません。 Person.male.where(live_in: Prefecture.find(13))、あるいは

class Person
  # ...  
  scope :living_in_tokyo, -> { where(live_in: Prefecture.find(13)) }
end

と言う scope の設定がある場合、 Person.male.living_in_tokyo の様に複数のスコープを連鎖させる事で、「東京都に住む男性」と言うスコープを作り出す事ができます。

この状態では、

males_living_in_tokyo = Person.male.living_in_tokyo
males_living_in_tokyo.create!(nickname: "Taro")

ご覧の通り、 nickname の設定だけでレコードを作る事ができる様になりました。

has_many な物もいける

続いて、 Prefecture から見た Person として次の様な has_many の関係を定義しましょう:

class Prefecture < ApplicationRecord
  has_many :people_living_here, class_name: :Person

  # tokyo のショートカットもついでに設定しておく  
  def self.tokyo
    find(13)
  end
end

この場合も、先ほどと同様の事が行えます:

males_living_in_tokyo = Prefecture.tokyo.people_living_here.male
males_living_in_tokyo.create!(nickname: "Jiro")

繰り返しますが、スコープは連鎖させても実態は ActiveRecord::Relation なので件数取得や削除、更新もお手のものです:

males_living_in_tokyo.count # 東京に住む男性の件数
males_living_in_tokyo.update_all "nickname = nickname || ' (tokyo)'" # 東京に住む全ての男性の nickname に " (tokyo)" を付け足す
males_living_in_tokyo.destroy_all # 東京に住む男性を削除

実は many-to-many でも行ける

モデルが持つ attribute や、 belongs_to で紐付くリレーションモデルは全て、当該モデルに所属するカラム (people の nicknamesexprefecture_id) にセットされるのでこの様な動作になるのは分かるのですが、 many-to-many の場合はどうでしょうか?

試しに、 Person モデルに prefectures_to_want_to_live_in と言う、 Prefecture と many-to-many な関係を作ってみます:

# schema
create_table "prefectures_person_wants_to_live_in", force: :cascade do |t|
  t.integer "prefecture_id"
  t.integer "person_id"
  t.index ["person_id"], name: "index_prefectures_person_wants_to_live_in_on_person_id"
  t.index ["prefecture_id"], name: "index_prefectures_person_wants_to_live_in_on_prefecture_id"
end

# models
class PrefecturePersonWantsToLiveIn < ApplicationRecord
  self.table_name = :prefectures_person_wants_to_live_in

  belongs_to :prefecture
  belongs_to :person
end

class Person < ApplicationRecord
  belongs_to :live_in, class_name: :Prefecture, foreign_key: :prefecture_id
  has_many :prefectures_person_wants_to_live_in, class_name: :PrefecturePersonWantsToLiveIn
  has_many :prefectures_to_want_to_live_in, through: :prefectures_person_wants_to_live_in, source: :prefecture
    
  # ...
end

この様なモデルの場合、 Prefecture では Prefecture.tokyo.people_wanting_to_live_here の様なスコープを取得できます。試しにこれを create! してレコードを作ってみましょう:

# 東京に住みたいと考えている人
people_wanting_to_live_in_tokyo = Prefecture.tokyo.people_wanting_to_live_here
person_wanting_to_live_in_tokyo = people_wanting_to_live_in_tokyo.create!(nickname: "Saburo", sex: :male, live_in: Prefecture.find(1))

SQL のログを見ると、きちんと “prefectures_person_wants_to_live_in” のレコードも一緒に作られた事が分かります。実際に person_wanting_to_live_in_tokyo.prefectures_to_want_to_live_in の結果は [Prefecture.tokyo] が返ってきます。便利ですね。

ただし、注意したいのは new でレコードを作った場合は many-to-many の関係は自動で生成されないと言う事です。 create! により、その場で作る必要があります。(create でも可能ですが、バリデーションエラーでレコードが戻ってきた場合、そのレコードについては new 同様に自動でリレーションはされないので注意)

ところで、 people_wanting_to_live_in_tokyo は Person のスコープ (ActiveRecord::Relation) になっています。 (Prefecture ではない点に注意) なので、更に以下の様にチェーンする事も勿論できます:

# 北海道に住む、東京に住みたいと考えている男性を作成
people_wanting_to_live_in_tokyo.male.create!(nickname: "Saburo", live_in: Prefecture.find(1))

sex の設定を省略できました。 Person に living_in_hokkaido とかがある場合、更にこれも省略できる事は言うまでもありませんね。

まとめ

今回は、 ActiveRecord::Relation の便利な機能についてまとめました。ごく基本的な機能ですが、使いこなすとコードの可読性がグッと上がりそうです。
また Rails は業務で4年くらい使っていて本家にプルリクをたまに送ったりするくらいには使っていますが、未だに新たな発見があり、その度によくできたフレームワークの1つだなと感心します。

← 前の投稿

docker run/exec 時にキャリッジリターンが混じる件の解決方法

次の投稿 →

Laravel のフロントエンドビルドツールが Vite に変わった

コメントを残す