Active Recordのdistinctを使った書き方の比較 | Dev Driven 開発・デザインチーム Active Recordのdistinctを使った書き方の比較 | 働くひとと組織の健康を創る iCARE

BLOG

Active Recordのdistinctを使った書き方の比較

こんにちは!メグミです!

今回は Active Recordのdistinctを使った書き方の比較
について書きたいと思います!

概要

データモデル

以下のようなテーブルがあるとします。

departments

id name
1 営業部
2 人事部
3 開発部

has_many :customers

customers

id fullname working_arrangement department_id
1 正社員 太郎 正社員 1
2 契約社員 花子 契約社員 2
3 業務委託 太郎 業務委託 3
4 アルバイト 花子 アルバイト 2
5 正社員 次郎 正社員 1

belongs_to :department
has_many :overtimes

overtimes

id customer_id hour year_month
1 3 40 2020-04-01
2 2 30 2020-04-01
3 1 80 2020-04-01
4 3 20 2020-05-01
5 2 10 2020-05-01
6 5 45 2020-05-01
7 4 0 2020-05-01
8 1 70 2020-05-01

belongs_to :customer

やりたいこと

その1:当該年月の残業時間データに紐づく部署名を配列で取得したい

最初に書いたコード

従業員の残業時間は年月単位で管理しており、
当該年月の残業時間データが存在する従業員の所属する部署名を一意で取得したい場合。

overtimes = Overtime.where(year_month: "2020-04-01")
customers = overtimes.map { |overtime| overtime.customer }
departments = customers.map { |c| c.department }.reject { |d| d.nil? }.uniq
department_names = departments.map { |d| d.name }
# => ["営業部", "人事部", "開発部"]

期待する値が取得できたので、レビューを依頼しました?
ところが、これでは問題が・・・!

発行されるSQLを見てみると

-- customers = overtimes.map { |overtime| overtime.customer }
SELECT  "customers".* FROM "customers" WHERE "customers"."deleted_at" IS NULL AND "customers"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
SELECT  "customers".* FROM "customers" WHERE "customers"."deleted_at" IS NULL AND "customers"."id" = $1 LIMIT $2  [["id", 2], ["LIMIT", 1]]
SELECT  "customers".* FROM "customers" WHERE "customers"."deleted_at" IS NULL AND "customers"."id" = $1 LIMIT $2  [["id", 3], ["LIMIT", 1]]
-- departments = customers.map { |c| c.department }.reject { |d| d.nil? }.uniq
SELECT  "departments".* FROM "departments" WHERE "departments"."deleted_at" IS NULL AND "departments"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
SELECT  "departments".* FROM "departments" WHERE "departments"."deleted_at" IS NULL AND "departments"."id" = $1 LIMIT $2  [["id", 2], ["LIMIT", 1]]
SELECT  "departments".* FROM "departments" WHERE "departments"."deleted_at" IS NULL AND "departments"."id" = $1 LIMIT $2  [["id", 3], ["LIMIT", 1]]

これでは、overtimesの数だけcustomerを取得する処理が行われてしまいます?
overtimesのデータが少なければそこまで問題なさそうですが、これが何万データもあった場合はその数だけ処理をすることになるため非効率です。

そして、次のように変更してみました。

department モデルから必要な値を取得するように変更した

overtimes = Overtime.where(year_month: "2020-04-01")
departments = Department.where(id: overtimes.joins(customer: :department).select("distinct departments.id"))
-- departments = Department.where(id: overtimes.joins(customer: :department).select("distinct departments.id"))
SELECT "departments".* FROM "departments" WHERE "departments"."deleted_at" IS NULL AND "departments"."id" IN (SELECT distinct departments.id FROM "overtimes" INNER JOIN "customers" ON "customers"."id" = "overtimes"."customer_id" AND "customers"."deleted_at" IS NULL INNER JOIN "departments" ON "departments"."id" = "customers"."department_id" AND "departments"."deleted_at" IS NULL WHERE "overtimes"."deleted_at" IS NULL AND "overtimes"."year_month" = $1)  [["year_month", "2020-04-01"]]

最初のコードは、条件で絞った overtimes に紐づく customer を取得し、
その customerdepartment_id を取得していました。

変更後のコードは、departmentモデルに対してovertimesの条件を指定するため、一回の処理で済みます?
これで、overtimesのデータが大量でも問題なさそうですね!

その2:部署名と同様に業務形態も取得したい

部署名と同様の条件で、従業員の業務形態( customerworking_arrangement )を取得する場合も見てみます。

最初に書いたコード

overtimes = Overtime.where(year_month: "2020-04-01")
customers = overtimes.map { |overtime| overtime.customer }
working_arrangements = customers.map { |c| c.working_arrangement }.uniq
# => ["正社員", "契約社員", "業務委託"]

これもさっきと同様で、overtimes の数だけ customer を取得しているため問題があります。

サブクエリを使って条件を絞る

overtimes = Overtime.where(year_month: "2020-04-01")
customers = Customer.select('distinct customers.working_arrangement').from(overtimes.joins(:customer).select('customers.*'), 'customers')
-- customers = Customer.select('distinct customers.working_arrangement').from(overtimes.joins(:customer).select('customers.*'), 'customers')
SELECT distinct customers.working_arrangement FROM (SELECT customers.* FROM "overtimes" INNER JOIN "customers" ON "customers"."id" = "overtimes"."customer_id" AND "customers"."deleted_at" IS NULL WHERE "overtimes"."deleted_at" IS NULL AND "overtimes"."year_month" = $1) customers WHERE "customers"."deleted_at" IS NULL  [["year_month", "2020-04-01"]]

社内のエンジニアのすんさんに相談したところ、こんな書き方もできることを知りました!
これで期待する値は取れましたが、もっといい方法がないかと考えていました。

merge を使って条件を絞る

overtimes = Overtime.where(year_month: "2020-04-01")
customers = Customer.select('distinct customers.working_arrangement').joins(:overtimes).merge(overtimes)
-- customers = Customer.select('distinct customers.working_arrangement').joins(:overtimes).merge(overtimes)
SELECT distinct customers.working_arrangement FROM "customers" INNER JOIN "overtimes" ON "overtimes"."customer_id" = "customers"."id" AND "overtimes"."deleted_at" IS NULL WHERE "customers"."deleted_at" IS NULL AND "overtimes"."deleted_at" IS NULL AND "overtimes"."year_month" = $1  [["year_month", "2020-04-01"]]

技術顧問の前島さんに相談したところ、mergeを使って条件を指定することを教えていただきました!
色々と試してみて、よくわからない状態に陥っていましたが、この書き方のほうが条件がわかりやすいですね。

最後に

色々な書き方があって、どのように書くべきかわかっていませんでした?
他のエンジニアに相談すると、それぞれ色々な書き方が出てきておもしろいなとも思いました。

今後は期待する値が取得できたからOKではなく、
無駄な処理が起きていないか、可読性や処理速度なども考えて実装をしていきたいと感じました?

おわり