TL;DR
前後文にdistinctが存在する(可能性がある)場合、pluckまたはselectを使うとき、id(またはその他一意性識別子)を一緒に入れないと、場合によって見つかりにくいBugが発生します。
OK:
user.purchases.distinct.pluck(:id, :amount).map(&:last).sum
# または
user.purchases.distinct.select(:id, :amount).map(&:amount).sum
NG:
user.purchases.distinct.pluck(:amount).sum
# または
user.purchases.distinct.select(:amount).map(&:amount).sum
起因
仕事で開発を行なった通販システムで、ある日から「会員の購入金額の合計データを出力する」という要望がありました。
開発する際に、「購入金額の合計を取るには、わざわざ全件をインスタンス化しなくていいじゃない」と思い、下記のようなコードを書きました。
user.purchases.pluck(:amount).sum
開発環境で何件購入してみて、ちゃんと計算しているように見えたので、そのままPR出しました。
そして後日、営業側から、「今月システムの購入金額の統計と銀行の振込合計が合わない」との問い合わせが来ました。
分析
いろいろ調べた結果、user.purchasesのスコープの中にはdistinctが存在していて、もしその後にpluck/selectを繋いだ場合、amountに対してdistinctを行うこととなっていることがわかりました。
# 見やすいために`distinct`を外に出します
user.purchases.distinct.pluck(:amount)
=> (4.8ms) SELECT DISTINCT "purchases"."amount" FROM "purchases" WHERE "purchases"."user_id" = $1 [["user_id", 42]]
ログが示したように、"purchases"."amount"に対してDISTINCTを行うよう、SQLが発行されました。
開発した際、たまたま違う値段の商品を購入してテストを行なったため、このBugが露呈しませんでした。
また、selectを使う時も同じです。
# 見やすいために`distinct`を外に出します
user.purchases.distinct.select(:amount)
=> Purchase Load (5.9ms) SELECT DISTINCT "purchases"."amount" FROM "purchases" WHERE "purchases"."user_id" = $1 [["user_id", 42]]
解決方法
普段distinctを使う時、だいたいはORなどを用いた際の重複排除だと考えられます。
その際DISTINCTの対象は"purchases".*ですが、IDなど一意性の識別子がある場合、pluckやselectにその識別子も一緒に入れれば、DISTINCTの誤爆を防げられます。
user.purchases.distinct.pluck(:id, :amount)
=> (4.1ms) SELECT DISTINCT "purchases"."id", "purchases"."amount" FROM "purchases" WHERE "purchases"."user_id" = $1 [["user_id", 42]]
当然、戻り値は変わりますので、その辺は適宜に処理すれば目的達成となりますでしょう。