👌

中間モデルの属性を関連先インスタンスの属性として取り出す(ActiveRecord)

takeyuweb2023/01/17に公開

たとえば、次のようなケース

  • 買い物かご Backet
  • 商品 Item
  • 買い物かごに入っている状態を表す中間モデル
  • 入っている数 quantity を中間モデルが持つ

このとき、買い物かごの商品リストと数量を取り出すには、どう書けばいい?と質問がありました。

class Backet < ApplicationRecord
  has_many :backet_items, dependent: :destroy
  has_many :items, through: :backet_item
end

class Item < ApplicationRecord
  validates :name, presence: true
  validates :price, presence: true
end

class BacketItem < ApplicationRecord
  belongs_to :backet
  belongs_to :item
  validates :quantity, presence: true
end

# こうではなく
backet.backet_items.includes(:item).each do |backet_item|
  p backet_item.item.name      # => りんご
  p backet_item.item.price     # => 250
  p backet_item.quantity  # => 5
end

# こうしたい (itemsではなくbacket_itemsのカラムの値をとりたい)
backet.items.each do |item|
  p item.name      # => りんご
  p item.price     # => 250
  p item.quantity  # => 5
end

select

has many through: の読み込みでは、自動で中間テーブルがJOINされます。

puts backet.items.to_sql
# SELECT "items".* FROM "items" INNER JOIN "backet_items" ON "items"."id" = "backet_items"."item_id" WHERE "backet_items"."backet_id" = 'c2d10446-33a0-48ad-8f1b-e4c5f489fffa'
=>

このため、中間テーブルのカラムはクエリメソッドの select で選択できます。

backet.items.select(:quantity, Item.arel_table[Arel.star]).each do |item|
  p item.name      # => りんご
  p item.price     # => 250
  p item.quantity  # => 5 取り出せた
end
# Item Load (1.3ms)  SELECT "quantity", "items".* FROM "items" INNER JOIN "backet_items" ON "items"."id" = "backet_items"."item_id" WHERE "backet_items"."backet_id" = $1  [["backet_id", "c2d10446-33a0-48ad-8f1b-e4c5f489fffa"]]

https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-select

select はブロックを渡すと Array#select と同様に振る舞い、引数を渡すとSELECT句を指定できます。

ただし、取得のたびに select(:quantity, Item.arel_table[Arel.star]) を書くのは冗長でDRYではありませし、読みづらいですね。

A. default_scope 引数を使う

has_many メソッドの default_scope で select を使うことでDRYになります。

class Backet < ApplicationRecord
  has_many :backet_items, dependent: :destroy
  has_many :items, -> { select(:quantity, arel_table[Arel.star]) }, through: :backet_items
end

backet.items.each do |item|
  p item.name      # => りんご
  p item.price     # => 250
  p item.quantity  # => 5
end

ただ、default scope は慎重に扱う必要があります。

https://techracho.bpsinc.jp/hachi8833/2021_11_04/47302

暗黙的に Itemquantity というメソッドが生えるのも気持ち悪いと感じるかもしれません。

B. has_many extension引数を使う

has_many メソッドの extension を使うと、クエリメソッドを追加できます。
これを使うと、人間が読みやすい形で、必要な時にだけ select を追加できます。

class Backet < ApplicationRecord
  has_many :backet_items, dependent: :destroy
  has_many :items, through: :backet_items do
    def with_quantity
      select(:quantity, arel_table[Arel.star])
    end
  end
end
# with_quantity で quantity を取得することを明示できる
backet.items.with_quantity.each do |item|
  p item.name      # => りんご
  p item.price     # => 250
  p item.quantity  # => 5 取り出せた
end
# => Item Load (0.5ms)  SELECT "quantity", "items".* FROM "items" INNER JOIN "backet_items" ON "items"."id" = "backet_items"."item_id" WHERE "backet_items"."backet_id" = $1  [["backet_id", "c2d10446-33a0-48ad-8f1b-e4c5f489fffa"]]

サンプルコード

https://github.com/takeyuweb/my_app/blob/main/app/models/backet.rb
https://github.com/takeyuweb/my_app/blob/e653a9d2543f8d6bf7f2d23557d52dc9dcd9c9e0/app/models/item.rb
https://github.com/takeyuweb/my_app/blob/bb8631d11a26c142faaab21ed4ab1908ccfd219b/app/models/backet_item.rb

まとめ

select クエリメソッドを使って選択することで、モデルに対応したテーブル以外のカラムも、モデルの属性と同じように扱うことができました。
また、デフォルトスコープやエクステンションを使うことで、読みやすく書きやすくすることができました。

タケユー・ウェブ株式会社

Ruby on Rails や AWS が得意なWebサービス受託開発会社です。 中小規模のWebサービスの新規開発の他、他の個人開発者などから引き継いで保守運用を行ったりしています。 新規開発、お手伝いや顧問、レガシーなRailsプロジェクトの保守など、ニーズにあわせて対応できます。ご相談ください。

Discussion

ログインするとコメントできます