このエントリーをはてなブックマークに追加

2016年12月13日火曜日

RailsでLEFT OUTER JOINを頑張っていた結果、アソシエーションで解決した話

こんにちは、Taroです。
今回の趣旨は、無駄に難しく考える必要はないということです。(勝手に悩み、ドツボにはまっておりました。)
例えば、下記のようなテーブルがあったとします。
例としてビールメーカーで作成しましたが、現実のものとは全く関係ありません。(妻夫木くんストラップも本当にあるかは知りません。)
companies(メーカー管理)
id company_name
1 キリン
2 アサヒ
3 サントリー
4 サッポロ
products(各メーカーの商品管理)
id company_id product_name price
1 1 ラガービール 100
2 1 一番搾り 300
3 2 スーパードライ 200
4 3 プレミアムモルツ 300
5 3 モルツ 200
6 3 プレミアムモルツブラック 500
7 4 サッポロビール 200
campaigns(各メーカーの実施しているキャンペーン管理)
id company_id campaign_name started_at ended_at
1 1 缶ビール1本無料!! 2013-12-01 00:00:00 2014-01-01 00:00:00
2 1 缶ビール1本無料!!(復活) 2016-12-01 00:00:00 2017-01-01 00:00:00
3 4 妻夫木君ストラップ 2016-12-10 00:00:00 2016-12-21 00:00:00

モデルは簡単に作成します。
class Company < ApplicationRecord
  has_many :products
  has_many :campaigns
end

class Product < ApplicationRecord
  belongs_to :company
end

class Campaign < ApplicationRecord
  belongs_to :company
end
ここで、メーカーの一覧を取得する際に、紐づく製品、キャンペーンも取得したいと思います。
ただ、条件がありまして、productsからはpriceが500円以下(今回の例だと全てですが。。。)、campaignsからは今日開催しているものです。
製品はまだしも、キャンペーンはメーカーによっては開催していない場合もあります。
N+1問題を考慮しincludesで事前ロードしつつ、紐づくテーブルに条件を設定するため、下記の命令で実行しました。
Company.includes([:products,campaigns]).
where('products.price <= 500').
where('campaigns.started_at <= ?', Time.zone.now).
where('campaigns.ended_at <= ?', Time.zone.now).
references([:products, :campaigns])
発行されるSQLと結果はこちらになります。
SELECT "companies"."id" AS t0_r0,
  "companies"."company_name" AS t0_r1,
  "companies"."created_at" AS t0_r2,
  "companies"."updated_at" AS t0_r3,
  "products"."id" AS t1_r0,
  "products"."company_id" AS t1_r1,
  "products"."product_name" AS t1_r2,
  "products"."price" AS t1_r3,
  "products"."created_at" AS t1_r4,
  "products"."updated_at" AS t1_r5,
  "campaigns"."id" AS t2_r0,
  "campaigns"."company_id" AS t2_r1,
  "campaigns"."campaign_name" AS t2_r2,
  "campaigns"."started_at" AS t2_r3,
  "campaigns"."ended_at" AS t2_r4,
  "campaigns"."created_at" AS t2_r5,
  "campaigns"."updated_at" AS t2_r6
FROM "companies"
LEFT OUTER JOIN "products" ON "products"."company_id" = "companies"."id"
LEFT OUTER JOIN "campaigns" ON "campaigns"."company_id" = "companies"."id"
WHERE (products.price <= 500)
  AND (campaigns.started_at <= '2016-12-12 11:22:12.523389')
  AND (campaigns.ended_at <= '2016-12-12 11:22:12.534654');

#<ActiveRecord::Relation [#<Company id: 1, company_name: "キリン", created_at: "2016-12-12 00:00:00", updated_at: "2016-12-12 00:00:00">]>
おかしい。。。キリンしか取得できない。。。
発行したSQLを確認すると、JOINした後に条件指定しております。
ON句の中に入れられないかと思い、路頭に迷っているところで、下記のようにモデルを書き換えました。
class Company < ApplicationRecord
  has_many :products
  has_many :products_price_under_500, -> { where('products.price <= 500') }, class_name: 'Product'
  has_many :campaigns
  has_many :campaigns_published, -> { where('campaigns.started_at <= ?', Time.zone.now).where('campaigns.ended_at <= ?', Time.zone.now) }, class_name: 'Campaign'
end
# ProductとCampaignは変わらないので省略。
アソシエーションの際に条件指定して取得するという、非常に基本的なところを忘れておりました。
一応、条件指定したくないケースもあると思うので、デフォルトとは別でつくりました。
これで書くと、先ほどのクエリも、短くなります。
Company.includes([:products_price_under_500,:campaigns_published])
SELECT "companies".* FROM "companies";
SELECT "products".* FROM "products" WHERE (products.price <= 500) AND "products"."company_id" IN (1, 2, 3, 4);
SELECT "campaigns".* FROM "campaigns" WHERE (campaigns.started_at <= '2016-12-12 11:30:03.468617')
    AND (campaigns.ended_at <= '2016-12-12 11:30:03.469799') AND "campaigns"."company_id" IN (1, 2, 3, 4);

#<ActiveRecord::Relation [#<Company id: 1, company_name: "キリン", created_at: "2016-12-12 00:00:00", updated_at: "2016-12-12 00:00:00">, 
#<Company id: 2, company_name: "アサヒ", created_at:-12-12 00:00:00", updated_at: "2016-12-12 00:00:00">, 
#<Company id: 3, company_name: "サントリー", created_at: "2016-12-12 00:00:00", updated_at: "2016-12-12 00:00:00">, 
#<Company id: 4, compane: "サッポロ", created_at: "2016-12-12 00:00:00", updated_at: "2016-12-12 00:00:00">]>
発行されるSQLは3本ですが、N+1問題を回避しつつ、条件指定できているかと思います。
もし、同じようなことをやられている方がいらっしゃいましたら、ぜひ参考にしてください。(反面教師的な意味でも。)

0 件のコメント:

コメントを投稿