RailsでunionとCASE文を使って複雑なORDER BY を実現する

こんにちは!kossyです!




さて、今回はRailsでunionを使って複数テーブルに跨がるORDER BY を実現する方法について、ブログに残してみたいと思います。



環境

Ruby 2.6.6
Rails 6.0.3.6
POstgreSQL 12系
MacOS Catalina



実装

抽象化した例文が以下かなと。

scope :super_order, -> () {
  query = <<-SQL.squish
    SELECT *
    FROM (
      SELECT テーブル1.*,
      CASE
        WHEN 条件 then 0
        WHEN 条件 then 2
        WHEN 条件 then 4
      end AS sort
      FROM テーブル名 as テーブル1
      UNION
      SELECT テーブル2.*,
      CASE
        WHEN 条件 then 1
        WHEN 条件 then 3
        WHEN 条件 then 5
      end AS sort
      FROM テーブル名 as テーブル2
    ) AS sorted_orders
    ORDER BY sort
  SQL

  find_by_sql(query)
}

CASE文で条件判定して数値を出して、その結果をsortという列名をつけています。
そしてUNIONを使って再度同様の処理をして、最後にsortの値でORDER BY をしています。

UNIONにはいくつか種類があり

・UNION ALL => 和結合
・INTERSECT => 積結合
・EXCEPT => 差結合

の3つがあります。

参考: [SQL] 8. クエリの結合 | TECHSCORE(テックスコア)

POstgreSQLの場合、UNION ALL を指定しない場合は INTERSECTでUNIONする挙動になります。

参考: 7.4. 問い合わせの結合



上記を踏まえて、例として、「役職が上の従業員を上位 & 男女別 で 売り上げを降順」でデータを引いてくるクエリを定義してみました。

  scope :super_order, -> () {
    query = <<-SQL.squish
      SELECT sort, price, *
      FROM (
        SELECT order_1.*,
        CASE
          WHEN pos.name = '支店長' then 0
          WHEN pos.name = '課長' then 2
          WHEN pos.name = '係長' then 4
          WHEN pos.name = '一般社員' then 6
        end AS sort
        FROM orders as order_1
        INNER JOIN employees AS emp ON emp.id = order_1.employee_id
        INNER JOIN positions AS pos ON pos.id = emp.position_id
        WHERE emp.sex = '0'
        UNION
        SELECT order_2.*,
        CASE
          WHEN pos.name = '支店長' then 1
          WHEN pos.name = '課長' then 3
          WHEN pos.name = '係長' then 5
          WHEN pos.name = '一般社員' then 7
        end AS sort
        FROM orders as order_2
        INNER JOIN employees AS emp ON emp.id = order_2.employee_id
        INNER JOIN positions AS pos ON pos.id = emp.position_id
        WHERE emp.sex = '1'
      ) AS sorted_orders
      ORDER BY sort, price
    SQL

    find_by_sql(query)
  }

rails cで試してみます。

$ order = Order.super_order.first
  Order Load (39.9ms)  SELECT sort, price, * FROM ( SELECT order_1.*, CASE WHEN pos.name = '支店長' then 0 WHEN pos.name = '課長' then 2 WHEN pos.name = '係長' then 4 WHEN pos.name = '一般社員' then 6 end AS sort FROM orders as order_1 INNER JOIN employees AS emp ON emp.id = order_1.employee_id INNER JOIN positions AS pos ON pos.id = emp.position_id WHERE emp.sex = '0' UNION SELECT order_2.*, CASE WHEN pos.name = '支店長' then 1 WHEN pos.name = '課長' then 3 WHEN pos.name = '係長' then 5 WHEN pos.name = '一般社員' then 7 end AS sort FROM orders as order_2 INNER JOIN employees AS emp ON emp.id = order_2.employee_id INNER JOIN positions AS pos ON pos.id = emp.position_id WHERE emp.sex = '1' ) AS sorted_orders ORDER BY sort, price DESC
=> #<Order:0x0000558c7b1011b8
 id: 2168,
 customer_name: "Bernier-Gibson",
 price: 10000000,
 status: "un_official",
 ordered_at: Tue, 08 Dec 2020,
 employee_id: 81,
 company_id: 1,
 department_id: 27,
 created_at: Fri, 19 Feb 2021 16:08:45 UTC +00:00,
 updated_at: Fri, 19 Feb 2021 16:08:45 UTC +00:00>

$ order.sort
=> 0

$ Order.super_order.pluck(:sort).compact.uniq
=>  [0, 1, 2, 3, 4, 5, 6, 7]

きちんとsort列を基にORDER BY されていることがわかりました。