「カテゴリ別小計と全体総計を同時に出す」をどう書くか
月次の売上レポートを作るとき、こんな表を求められることがある。
| カテゴリ | 月 | 売上 |
|---|---|---|
| ヘアケア | 2024-10 | 3,200,000 |
| ヘアケア | 2024-11 | 3,800,000 |
| ヘアケア 小計 | — | 7,000,000 |
| スキンケア | 2024-10 | 2,400,000 |
| スキンケア | 2024-11 | 2,900,000 |
| スキンケア 小計 | — | 5,300,000 |
| 総計 | — | 12,300,000 |
カテゴリ別・月別の明細と、カテゴリごとの小計、そして全体総計が1つの表に並んでいる。
これを愚直に書くと「明細クエリ」「カテゴリ別小計クエリ」「総計クエリ」を UNION ALL で繋げる方法になる。3本のクエリを書いて貼り合わせる手間は大きく、同じロジックを3箇所に書くメンテナンスコストも高い。
GROUP BY ROLLUP を使えばこれを 1本のクエリ で実現できる。
ROLLUP とは何か
GROUP BY ROLLUP(col1, col2) は、次の3種類の集約を1本のクエリで同時に計算する。
ROLLUP(category, month) の場合:
1. category × month ごとの集計(明細)
2. category ごとの集計(小計:monthをNULLで集約)
3. 全体の集計(総計:category・month両方をNULLで集約)カラムを右から順に外していき、段階的に集約レベルを上げていくイメージだ。
使用するテーブル
-- order_items(注文明細)
-- order_id : 注文ID
-- product_id : 商品ID
-- category : カテゴリ名
-- quantity : 数量
-- unit_price : 単価
-- orders(注文テーブル)
-- order_id : 注文ID
-- order_date : 注文日
-- status : 'completed' / 'cancelled'STEP 1 ― GROUP BY ROLLUP の基本
まずカテゴリ×月の売上に ROLLUP を適用してみる。
-- STEP1: ROLLUP でカテゴリ別小計・総計を一発で出す
SELECT
oi.category,
DATE_TRUNC('month', o.order_date) AS sales_month,
SUM(oi.quantity * oi.unit_price) AS total_sales,
COUNT(DISTINCT o.order_id) AS order_count
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
WHERE
o.status = 'completed'
AND o.order_date >= DATE '2024-10-01'
AND o.order_date < DATE '2025-01-01'
GROUP BY ROLLUP(oi.category, DATE_TRUNC('month', o.order_date))
ORDER BY
oi.category NULLS LAST, -- NULLを最後に(総計行)
sales_month NULLS LAST; -- NULLを最後に(小計行)出力イメージ
| category | sales_month | total_sales | order_count |
|---|---|---|---|
| ヘアケア | 2024-10-01 | 3,200,000 | 284 |
| ヘアケア | 2024-11-01 | 3,800,000 | 341 |
| ヘアケア | 2024-12-01 | 4,100,000 | 378 |
| ヘアケア | NULL | 11,100,000 | 1,003 |
| スキンケア | 2024-10-01 | 2,400,000 | 198 |
| スキンケア | 2024-11-01 | 2,900,000 | 241 |
| スキンケア | 2024-12-01 | 3,200,000 | 276 |
| スキンケア | NULL | 8,500,000 | 715 |
| NULL | NULL | 19,600,000 | 1,718 |
小計行(カテゴリごとの合計)は sales_month = NULL、総計行は category = NULL かつ sales_month = NULL で表現される。
STEP 2 ― GROUPING() で「NULL は集計起因か、データ起因か」を区別する
ここで問題が起きる。category が NULL なのは「総計行」だからなのか、それとも「もともとカテゴリが未設定の商品」だからなのかを、NULL だけでは判別できない。
GROUPING() 関数がこれを解決する。ROLLUP の集約によって NULL になった場合は 1、元のデータが NULL だった場合は 0 を返す。
-- STEP2: GROUPING() で集計行を識別する
SELECT
oi.category,
DATE_TRUNC('month', o.order_date) AS sales_month,
SUM(oi.quantity * oi.unit_price) AS total_sales,
-- GROUPING():ROLLUPによるNULLなら1、データのNULLなら0
GROUPING(oi.category) AS is_category_subtotal,
GROUPING(DATE_TRUNC('month', o.order_date)) AS is_month_subtotal,
-- 行の種類を人間が読める形で表示
CASE
WHEN GROUPING(oi.category) = 1
AND GROUPING(DATE_TRUNC('month', o.order_date)) = 1 THEN '【総計】'
WHEN GROUPING(DATE_TRUNC('month', o.order_date)) = 1 THEN '【小計】'
ELSE '明細'
END AS row_type
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
WHERE
o.status = 'completed'
AND o.order_date >= DATE '2024-10-01'
AND o.order_date < DATE '2025-01-01'
GROUP BY ROLLUP(oi.category, DATE_TRUNC('month', o.order_date))
ORDER BY
GROUPING(oi.category) ASC,
oi.category NULLS LAST,
GROUPING(DATE_TRUNC('month', o.order_date)) ASC,
sales_month NULLS LAST;出力イメージ
| category | sales_month | total_sales | is_category_subtotal | is_month_subtotal | row_type |
|---|---|---|---|---|---|
| ヘアケア | 2024-10-01 | 3,200,000 | 0 | 0 | 明細 |
| ヘアケア | 2024-11-01 | 3,800,000 | 0 | 0 | 明細 |
| ヘアケア | NULL | 11,100,000 | 0 | 1 | 【小計】 |
| スキンケア | 2024-10-01 | 2,400,000 | 0 | 0 | 明細 |
| スキンケア | NULL | 8,500,000 | 0 | 1 | 【小計】 |
| NULL | NULL | 19,600,000 | 1 | 1 | 【総計】 |
GROUPING() が 1 の行が集計起因の NULL だと正確に判定されている。
STEP 3 ― NULL を見やすいラベルに変換する(完成版)
レポートに出力するために、NULL を「小計」「総計」などの文字列に置き換える。
-- STEP3: NULL を読みやすいラベルに変換した完成版
SELECT
-- NULLを集計ラベルに変換
CASE
WHEN GROUPING(oi.category) = 1 THEN '【全カテゴリ合計】'
ELSE oi.category
END AS category,
CASE
WHEN GROUPING(DATE_TRUNC('month', o.order_date)) = 1 THEN '小計/合計'
ELSE CAST(DATE_TRUNC('month', o.order_date) AS VARCHAR)
END AS sales_month,
SUM(oi.quantity * oi.unit_price) AS total_sales,
COUNT(DISTINCT o.order_id) AS order_count,
ROUND(AVG(oi.quantity * oi.unit_price), 0) AS avg_line_amount,
-- 行の種類フラグ(後続処理・BIツールへの連携用)
GROUPING(oi.category) AS is_total_row,
GROUPING(DATE_TRUNC('month', o.order_date)) AS is_subtotal_row
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
WHERE
o.status = 'completed'
AND o.order_date >= DATE '2024-10-01'
AND o.order_date < DATE '2025-01-01'
GROUP BY ROLLUP(oi.category, DATE_TRUNC('month', o.order_date))
ORDER BY
GROUPING(oi.category) ASC,
oi.category NULLS LAST,
GROUPING(DATE_TRUNC('month', o.order_date)) ASC,
DATE_TRUNC('month', o.order_date) NULLS LAST;
完成した出力イメージ
| category | sales_month | total_sales | order_count | avg_line_amount |
|---|---|---|---|---|
| ヘアケア | 2024-10-01 | 3,200,000 | 284 | 11,268 |
| ヘアケア | 2024-11-01 | 3,800,000 | 341 | 11,143 |
| ヘアケア | 2024-12-01 | 4,100,000 | 378 | 10,847 |
| ヘアケア | 小計/合計 | 11,100,000 | 1,003 | — |
| スキンケア | 2024-10-01 | 2,400,000 | 198 | 12,121 |
| スキンケア | 2024-11-01 | 2,900,000 | 241 | 12,033 |
| スキンケア | 2024-12-01 | 3,200,000 | 276 | 11,594 |
| スキンケア | 小計/合計 | 8,500,000 | 715 | — |
| 【全カテゴリ合計】 | 小計/合計 | 19,600,000 | 1,718 | — |
CUBE ― すべての組み合わせで集計する
ROLLUP が「左から順に集約レベルを上げる」のに対して、CUBE はすべての列の組み合わせパターンで集計する。
CUBE(category, month) が生成する集計パターン:
1. category × month(明細)
2. category のみ(月をまたいだカテゴリ小計)
3. month のみ(カテゴリをまたいだ月別合計) ← ROLLUPにはないパターン
4. 全体総計ROLLUP との違いは「3. month のみの集計」が加わる点だ。
-- CUBE: カテゴリ別・月別の両方向の小計を同時に出す
SELECT
CASE WHEN GROUPING(oi.category) = 1
THEN '全カテゴリ' ELSE oi.category END AS category,
CASE WHEN GROUPING(DATE_TRUNC('month', o.order_date)) = 1
THEN '全月'
ELSE CAST(DATE_TRUNC('month', o.order_date) AS VARCHAR)
END AS sales_month,
SUM(oi.quantity * oi.unit_price) AS total_sales,
GROUPING(oi.category) AS cat_grouped,
GROUPING(DATE_TRUNC('month', o.order_date)) AS month_grouped
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
WHERE
o.status = 'completed'
AND o.order_date >= DATE '2024-10-01'
AND o.order_date < DATE '2025-01-01'
GROUP BY CUBE(oi.category, DATE_TRUNC('month', o.order_date))
ORDER BY cat_grouped ASC, oi.category NULLS LAST, month_grouped ASC, sales_month NULLS LAST;CUBE の追加出力(ROLLUPにはない行)
| category | sales_month | total_sales | cat_grouped | month_grouped |
|---|---|---|---|---|
| ヘアケア | 2024-10-01 | 3,200,000 | 0 | 0 |
| … | … | … | … | … |
| 全カテゴリ | 2024-10-01 | 6,800,000 | 1 | 0 |
| 全カテゴリ | 2024-11-01 | 8,100,000 | 1 | 0 |
| 全カテゴリ | 2024-12-01 | 9,700,000 | 1 | 0 |
| 全カテゴリ | 全月 | 24,600,000 | 1 | 1 |
cat_grouped = 1, month_grouped = 0 の行が「全カテゴリ合計の月別推移」で、ROLLUP にはないCUBE 特有のパターンだ。月次の全体売上推移を同時に取りたいときに役立つ。
ROLLUP vs CUBE の使い分け
| 観点 | ROLLUP | CUBE |
|---|---|---|
| 生成パターン数 | N + 1(列数 + 1) | 2^N(列数の2乗) |
| 用途 | 階層構造の集計(カテゴリ→全体) | 任意の次元の組み合わせ分析 |
| 典型的な使いどころ | 月次レポートの小計・総計 | クロス集計・ピボットテーブル |
| 出力行数 | 少なめ | 多め(列数が増えると爆発的に増える) |
列数が多い場合 CUBE の出力行数は急増する。3列なら8パターン、4列なら16パターンだ。実務では2〜3列に留めるのが現実的だ。
応用:3階層の ROLLUP(地域→カテゴリ→月)
3つの次元を持つ集計表も1本で書ける。
-- 地域→カテゴリ→月の3階層 ROLLUP
SELECT
CASE WHEN GROUPING(c.region) = 1 THEN '全地域' ELSE c.region END AS region,
CASE WHEN GROUPING(oi.category) = 1 THEN '全カテゴリ' ELSE oi.category END AS category,
CASE WHEN GROUPING(DATE_TRUNC('month', o.order_date)) = 1
THEN '小計' ELSE CAST(DATE_TRUNC('month', o.order_date) AS VARCHAR)
END AS sales_month,
SUM(oi.quantity * oi.unit_price) AS total_sales,
GROUPING(c.region) AS r_grp,
GROUPING(oi.category) AS c_grp,
GROUPING(DATE_TRUNC('month', o.order_date)) AS m_grp
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.status = 'completed'
GROUP BY ROLLUP(c.region, oi.category, DATE_TRUNC('month', o.order_date))
ORDER BY r_grp, c.region NULLS LAST, c_grp, oi.category NULLS LAST, m_grp, sales_month NULLS LAST;3階層のROLLUPは次の4種類の行を生成する。
1. 地域 × カテゴリ × 月(明細)
2. 地域 × カテゴリ(月をまたいだ小計)
3. 地域(カテゴリ・月をまたいだ小計)
4. 全体総計実務での運用ヒント
① ORDER BY での NULL の扱い
ROLLUPで生成された小計・総計行はNULLを持つため、通常の ORDER BY では位置が不定になる(エンジンによってNULLを先頭または末尾に置く)。ORDER BY GROUPING(...) ASC でグルーピングの有無を優先し、その後に実際の値でソートすると意図した順序になる。
② avg_line_amount の総計行は「平均の平均」にならないよう注意
ROLLUP の集計行では AVG は各行ではなく全対象行の平均を再計算する。「カテゴリ別平均の平均」ではなく「全行の平均」が出るため、一般的には正しい動作だ。しかし「明細行の平均値の平均」が欲しい場合は AVG(avg_line_amount) ではなく SUM(total_sales) / SUM(order_count) のように分子・分母を別途集計して割る必要がある。
③ Treasure Data(Presto)での ROLLUP / CUBE の動作
Presto では GROUP BY ROLLUP(...) と GROUP BY CUBE(...) の両方がサポートされている。GROUPING() 関数も使用可能だ。ただし Presto の一部バージョンでは ROLLUP の ORDER BY 句に集計関数を直接書けない場合があるため、GROUPING(col) をSELECT句で定義してから ORDER BY で参照する書き方が安全だ。
まとめ
ROLLUP と CUBE の使いどころを整理する。
GROUP BY ROLLUP(A, B)— 「A×Bの明細」「Aの小計」「総計」を1本で出す。月次レポートの定番GROUPING(col)— ROLLUPによるNULLとデータのNULLを区別するために必ず使うCASE WHEN GROUPING(...) = 1— NULLをラベル(「小計」「全カテゴリ」)に変換してレポートを整えるGROUP BY CUBE(A, B)— AとBの全組み合わせパターンを生成する。クロス集計に向くORDER BY GROUPING(...) ASC— 小計・総計行を明細行の後ろに正しく並べるために必要
UNION ALL で3本のクエリを貼り合わせる手間を1本にまとめられるだけでなく、集計ロジックの一元管理というメンテナンス上の利点も大きい。月次レポートを定期バッチで自動生成する場面では特に価値が高い。