👌

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

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

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

Discussion