#22│ROLLUP / CUBE で小計・総計つき集計表を一発で作る


「カテゴリ別小計と全体総計を同時に出す」をどう書くか

月次の売上レポートを作るとき、こんな表を求められることがある。

カテゴリ売上
ヘアケア2024-103,200,000
ヘアケア2024-113,800,000
ヘアケア 小計7,000,000
スキンケア2024-102,400,000
スキンケア2024-112,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を最後に(小計行)

出力イメージ

categorysales_monthtotal_salesorder_count
ヘアケア2024-10-013,200,000284
ヘアケア2024-11-013,800,000341
ヘアケア2024-12-014,100,000378
ヘアケアNULL11,100,0001,003
スキンケア2024-10-012,400,000198
スキンケア2024-11-012,900,000241
スキンケア2024-12-013,200,000276
スキンケアNULL8,500,000715
NULLNULL19,600,0001,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;

出力イメージ

categorysales_monthtotal_salesis_category_subtotalis_month_subtotalrow_type
ヘアケア2024-10-013,200,00000明細
ヘアケア2024-11-013,800,00000明細
ヘアケアNULL11,100,00001【小計】
スキンケア2024-10-012,400,00000明細
スキンケアNULL8,500,00001【小計】
NULLNULL19,600,00011【総計】

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;

完成した出力イメージ

categorysales_monthtotal_salesorder_countavg_line_amount
ヘアケア2024-10-013,200,00028411,268
ヘアケア2024-11-013,800,00034111,143
ヘアケア2024-12-014,100,00037810,847
ヘアケア小計/合計11,100,0001,003
スキンケア2024-10-012,400,00019812,121
スキンケア2024-11-012,900,00024112,033
スキンケア2024-12-013,200,00027611,594
スキンケア小計/合計8,500,000715
【全カテゴリ合計】小計/合計19,600,0001,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にはない行)

categorysales_monthtotal_salescat_groupedmonth_grouped
ヘアケア2024-10-013,200,00000
全カテゴリ2024-10-016,800,00010
全カテゴリ2024-11-018,100,00010
全カテゴリ2024-12-019,700,00010
全カテゴリ全月24,600,00011

cat_grouped = 1, month_grouped = 0 の行が「全カテゴリ合計の月別推移」で、ROLLUP にはないCUBE 特有のパターンだ。月次の全体売上推移を同時に取りたいときに役立つ。


ROLLUP vs CUBE の使い分け

観点ROLLUPCUBE
生成パターン数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 の一部バージョンでは ROLLUPORDER BY 句に集計関数を直接書けない場合があるため、GROUPING(col) をSELECT句で定義してから ORDER BY で参照する書き方が安全だ。


まとめ

ROLLUPCUBE の使いどころを整理する。

  1. GROUP BY ROLLUP(A, B) — 「A×Bの明細」「Aの小計」「総計」を1本で出す。月次レポートの定番
  2. GROUPING(col) — ROLLUPによるNULLとデータのNULLを区別するために必ず使う
  3. CASE WHEN GROUPING(...) = 1 — NULLをラベル(「小計」「全カテゴリ」)に変換してレポートを整える
  4. GROUP BY CUBE(A, B) — AとBの全組み合わせパターンを生成する。クロス集計に向く
  5. ORDER BY GROUPING(...) ASC — 小計・総計行を明細行の後ろに正しく並べるために必要

UNION ALL で3本のクエリを貼り合わせる手間を1本にまとめられるだけでなく、集計ロジックの一元管理というメンテナンス上の利点も大きい。月次レポートを定期バッチで自動生成する場面では特に価値が高い。


MarTech Farmをもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む