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]]
当然、戻り値は変わりますので、その辺は適宜に処理すれば目的達成となりますでしょう。