Ruby on Rails で WITH 句を ActiveRecord のリレーションとして扱う話


My Redmine

前田 稔です。弊社では Webアプリケーションフレームワークの Ruby on Rails を利用した顧客・請求・入金管理システムがあり、 SQL のクエリ内で WITH 句を使用する機会がありました。本記事作成時点では Active Record クエリインターフェイス - Railsガイドwith メソッドの記載がなかったため、参考になればと思い使い方の紹介をします。なお、SQL の実行計画やパフォーマンス・チューニングの掘り下げは扱いません。

本記事用に用意した Ruby on Rails アプリケーション概要

各種バージョンは次のとおりです。今回は SQLite を使用していますので、他のデーターベースシステムで試される場合は WITH 句のサポート状況をご確認ください。

$ sqlite3 --version
3.40.1 2022-12-28 14:03:47 df5c253c0b3dd24916e4ec7cf77d3db5294cc9fd45ae7b9c5e82ad8197f3alt1

$ rails --version
Rails 8.0.2.1

$ ruby --version
ruby 3.4.4 (2025-05-14 revision a38531fd3f) +PRISM [aarch64-linux]

本記事用に用意した Ruby on Rails アプリケーションは顧客と請求、入金を扱うモデルがあるものとします。

題材とする状況

請求と入金を突き合わせて管理する場面が多くあります。 今回は請求額と入金額が一致していない顧客を抽出するケースを例にします。

考慮する条件は次のとおりです。

WITH 句を使わずに実現した例

最終的には顧客を抽出したいので、以下のように結合していきます。

その上で、HAVING 句を使って集合に対して「請求合計 ≠ 入金合計(または NULL)」の顧客を絞り込みます。

invoice_totals =
  Customer.joins(:invoices)
          .where(invoices: { due_date: ..Date.today })
          .select(:id, 'SUM(invoices.amount) AS total_invoice_amount')
          .group(:id)

payment_totals =
  Customer.left_outer_joins(:payments)
          .where(payments: { payment_date: ..Date.today })
          .or(Customer.left_outer_joins(:payments).where(payments: { payment_date: nil }))
          .select(:id, 'SUM(payments.amount) AS total_payment_amount')
          .group(:id)

# データベースシステムによっては動作しない場合があります
customers = invoice_totals.merge(payment_totals)
                          .having('total_invoice_amount != COALESCE(total_payment_amount, 0)')

発行されるSQLは次のとおりです。

SELECT
    "customers"."id",
    SUM(invoices.amount) AS total_invoice_amount,
    SUM(payments.amount) AS total_payment_amount
FROM
    "customers"
    INNER JOIN "invoices"
        ON "invoices"."customer_id" = "customers"."id"
    LEFT OUTER JOIN "payments"
        ON "payments"."customer_id" = "customers"."id"
WHERE
    "invoices"."due_date" <= '2025-09-03'
  AND (
        "payments"."payment_date" <= '2025-09-03'
        OR "payments"."payment_date" IS NULL
    )
GROUP BY
    "customers"."id"
HAVING
    (total_invoice_amount != COALESCE(total_payment_amount, 0))

WITH 句を使った例

with メソッドにて一時的なビューとしての名前と ActiveRecord のリレーションを指定することで、内部結合や外部左結合でも利用できるようになります。

参考情報:

customers =
  Customer.with(
    temp_invoices: Invoice.where(due_date: ..Date.today)
                          .select(:customer_id, 'SUM(invoices.amount) AS total_amount')
                          .group(:customer_id),
    temp_payments: Payment.where(payment_date: ..Date.today)
                          .select(:customer_id, 'SUM(payments.amount) AS total_amount')
                          .group(:customer_id)
  )
  .left_outer_joins(:temp_payments)
  .joins(:temp_invoices)
  .where('temp_invoices.total_amount != COALESCE(temp_payments.total_amount, 0)')

発行されるSQLは次のようになります。

WITH "temp_invoices" AS (
    SELECT
        "invoices"."customer_id",
        SUM(invoices.amount) AS total_amount
    FROM
        "invoices"
    WHERE
        "invoices"."due_date" <= '2025-09-03'
    GROUP BY
        "invoices"."customer_id"
), "temp_payments" AS (
    SELECT
        "payments"."customer_id",
        SUM(payments.amount) AS total_amount
    FROM
        "payments"
    WHERE
        "payments"."payment_date" <= '2025-09-03'
    GROUP BY
        "payments"."customer_id"
)

SELECT
    "customers".*
FROM
    "customers"
    LEFT OUTER JOIN "temp_payments"
        ON "temp_payments"."customer_id" = "customers"."id"
    INNER JOIN "temp_invoices"
        ON "temp_invoices"."customer_id" = "customers"."id"
WHERE (
    temp_invoices.total_amount != COALESCE(temp_payments.total_amount, 0)
)

おわりに

ActiveRecord の with メソッドを使うと、複雑なサブクエリや結合を整理して書けるため、モデル上のコードが読みやすくなる傾向があります。 今回は扱いませんでしたが with メソッドは UNION ALL による和集合や、FROM 句や WHERE 句のサブクエリの置き換えにも利用できます。詳しくは ActiveRecord::QueryMethods - Rails API をご覧ください。

既存のサブクエリを整理したいときや SQL をよりわかりやすくしたいときに、ぜひ試してみてください。本記事が参考になれば幸いです。

My Redmine
こちらの記事もオススメです!
ruby/ruby.wasm を使って Redmine をブラウザ内で動かす話
Ruby on Rails アプリケーションである Redmine の Wasm 化を試しました
JANOG56ミーティングにブース出展しました
島根県松江市で開催されたJANOG56ミーティングにRuby Sponsorとしてブース出展しました。
GoogleフォトからAmazon Photosへ移行した記録と使ってみた感想
GoogleフォトからAmazon Photosへ移行しました。容量削減の手順や移行の注意点をまとめて紹介します。
JANOG56に初参加してきました
初めてのJANOG参加。初心者や若者のサポート、議論を活発にするための工夫がたくさん詰まっていて、ネットワークに詳しくない私でも楽しめました。
自分以外の家族のチケットを手配する ANA編
家族のANA国内線往復チケットを手配し、家族のスマートフォンに登録する手順を紹介します
ファーエンドテクノロジーからのお知らせ(2025/08/20更新)
Open Developers Conference 2025(9/6開催)登壇・ブース出展
Open Developers Conference 2025で登壇・ブースを出展します。
【AIがRedmineのチケットを要約】チケット内容が瞬時に分かる新機能「AIチケット要約」をMy Redmineで提供開始!
Redmineのクラウドサービス『My Redmine JP Edition』で、チケット内容を要約する新機能「AIチケット要約」が使えるようになりました。
RubyWorld Conference 2025 (11/6・7開催) にPlatinumスポンサーとして協賛
2025年11月6日(木)〜7日(金)に島根県松江市で開催される「RubyWorld Conference 2025」にPlatinumスポンサーとして協賛しています。
Redmineの最新情報をメールでお知らせする「Redmine News」配信中
新バージョンやセキュリティ修正のリリース情報、そのほか最新情報を迅速にお届け