SQLのWITH ROLLUPで小計・総計を出す方法(MySQL)

集計レポートの画面 SQL

「地域別の売上一覧に、小計と総計も付けてほしい」。集計レポートでよくある依頼です。GROUP BYにWITH ROLLUPを付けると、明細・小計・総計を1本のクエリでまとめて出せます。この記事では、売上テーブルを例にWITH ROLLUPの基本を説明します。続いて、GROUPING()関数で小計行に「小計」「総計」のラベルを付ける方法、小計だけを取り出す方法を紹介します。後半では、元データのNULLと見分けがつかなくなる問題など、つまずきやすい点もまとめました。

このシナリオで考える

地域とカテゴリごとの売上を記録した sales テーブルを使います。データはわずか6行です。小計や総計を手で計算して確かめられるように、あえて少なくしてあります。

CREATE TABLE sales (
  id       INT PRIMARY KEY AUTO_INCREMENT,
  region   VARCHAR(20),
  category VARCHAR(20) NOT NULL,
  amount   INT NOT NULL
);

INSERT INTO sales (region, category, amount) VALUES
  ('東日本', '食品', 1200),
  ('東日本', '食品',  800),
  ('東日本', '雑貨',  500),
  ('西日本', '食品',  900),
  ('西日本', '雑貨',  700),
  ('西日本', '雑貨',  300);

region はNULLを入れられる定義にしています。この点は後半の「よくある落とし穴」で使います。

WITH ROLLUPの基本:総計行を1行足す

WITH ROLLUPは、GROUP BYの結果に「1つ上の階層の合計行」を足す修飾子です。まずは地域ごとの合計に、総計を付けてみます。

SELECT region, SUM(amount) AS total
FROM sales
GROUP BY region WITH ROLLUP;
+--------+-------+
| region | total |
+--------+-------+
| 東日本 |  2500 |
| 西日本 |  1900 |
| NULL   |  4400 |
+--------+-------+

最後の行が、WITH ROLLUPで足された総計行です。この行では、グループ分けに使った列(ここでは region)がNULLになります。「すべての地域をまとめた」という意味のNULLと覚えてください。

SUMだけでなく、COUNTやAVGも総計行で計算されます。ただしAVGの総計行は「地域ごとの平均の平均」ではありません。元の6行すべての平均(4400 ÷ 6 ≒ 733.3)になる点に気をつけましょう。

列を2つ並べると小計と総計が出る

GROUP BYに列を2つ書くと、WITH ROLLUPは小計と総計の両方を足します。地域とカテゴリで集計してみましょう。

SELECT region, category,
       SUM(amount) AS total,
       COUNT(*)    AS cnt
FROM sales
GROUP BY region, category WITH ROLLUP;
+--------+----------+-------+-----+
| region | category | total | cnt |
+--------+----------+-------+-----+
| 東日本 | 雑貨     |   500 |   1 |
| 東日本 | 食品     |  2000 |   2 |
| 東日本 | NULL     |  2500 |   3 |
| 西日本 | 雑貨     |  1000 |   2 |
| 西日本 | 食品     |   900 |   1 |
| 西日本 | NULL     |  1900 |   3 |
| NULL   | NULL     |  4400 |   6 |
+--------+----------+-------+-----+

category だけがNULLの行は地域ごとの小計、両方がNULLの行は総計です。出てくる行の種類を表にまとめます。

行の種類 region category 集計の範囲
明細 値あり 値あり 地域×カテゴリ
小計 値あり NULL 地域ごと
総計 NULL NULL 全体

小計は、右側の列から順に外して作られます。そのため、GROUP BYに書く列の順番が結果を左右します。GROUP BY category, region WITH ROLLUP と書けば、カテゴリごとの小計に変わります。「大きい区分を左、細かい区分を右」と覚えておくと迷いません。

GROUPING()で小計行にラベルを付ける

NULLのままでは、報告資料として読みにくいでしょう。MySQL 8.0では、GROUPING()関数で小計・総計の行を見分けられます。WITH ROLLUPによってNULLになった列なら1、それ以外なら0を返す関数です。

SELECT
  CASE WHEN GROUPING(region) = 1 THEN '総計'
       ELSE region END AS region_label,
  CASE
    WHEN GROUPING(region) = 1   THEN ''
    WHEN GROUPING(category) = 1 THEN '小計'
    ELSE category
  END AS category_label,
  SUM(amount) AS total
FROM sales
GROUP BY region, category WITH ROLLUP;
+--------------+----------------+-------+
| region_label | category_label | total |
+--------------+----------------+-------+
| 東日本       | 雑貨           |   500 |
| 東日本       | 食品           |  2000 |
| 東日本       | 小計           |  2500 |
| 西日本       | 雑貨           |  1000 |
| 西日本       | 食品           |   900 |
| 西日本       | 小計           |  1900 |
| 総計         |                |  4400 |
+--------------+----------------+-------+

そのまま表計算ソフトに貼れる形になりました。列が1つだけなら、IF(GROUPING(region), '総計', region) のように短く書くこともできます。別名を元の列名と変えているのは、読み手が「加工後の列」だと分かるようにするためです。

小計だけを取り出す・並び順を変える

GROUPING()はHAVING句でも使えます。明細は要らず、小計と総計だけ欲しい場面で便利です。

SELECT region, SUM(amount) AS total
FROM sales
GROUP BY region, category WITH ROLLUP
HAVING GROUPING(category) = 1;
+--------+-------+
| region | total |
+--------+-------+
| 東日本 |  2500 |
| 西日本 |  1900 |
| NULL   |  4400 |
+--------+-------+

GROUPING()に複数の列を渡すと、各列の判定結果をビットとして組み合わせた数を返します。右端の引数がいちばん下の桁です。

行の種類 GROUPING(region) GROUPING(category) GROUPING(region, category)
明細 0 0 0
小計 0 1 1
総計 1 1 3

並び順も変えられます。MySQL 8.0.12以降では、WITH ROLLUPとORDER BYを一緒に使えるようになりました。次の例では、総計を先頭に置き、各地域の小計を明細の上に出しています。

SELECT region, category, SUM(amount) AS total
FROM sales
GROUP BY region, category WITH ROLLUP
ORDER BY GROUPING(region) DESC, region,
         GROUPING(category) DESC, category;
+--------+----------+-------+
| region | category | total |
+--------+----------+-------+
| NULL   | NULL     |  4400 |
| 東日本 | NULL     |  2500 |
| 東日本 | 雑貨     |   500 |
| 東日本 | 食品     |  2000 |
| 西日本 | NULL     |  1900 |
| 西日本 | 雑貨     |  1000 |
| 西日本 | 食品     |   900 |
+--------+----------+-------+

8.0.11以前では、この書き方はエラーになります。その場合はWITH ROLLUPのクエリを派生テーブル(FROM句のサブクエリ)で包み、外側で並べ替えてください。

よくある落とし穴

いちばん多いのは、元データのNULLと総計行のNULLが区別できなくなる問題です。地域が未入力の売上を1行足してみます。

INSERT INTO sales (region, category, amount)
VALUES (NULL, '食品', 400);

SELECT region, SUM(amount) AS total
FROM sales
GROUP BY region WITH ROLLUP;
+--------+-------+
| region | total |
+--------+-------+
| NULL   |   400 |
| 東日本 |  2500 |
| 西日本 |  1900 |
| NULL   |  4800 |
+--------+-------+

NULLの行が2つあり、どちらが総計か見た目では分かりません。GROUPING()で総計を判定してから、残ったNULLをCOALESCEで置き換えるのが確実です。

SELECT
  CASE
    WHEN GROUPING(region) = 1 THEN '総計'
    ELSE COALESCE(region, '未設定')
  END AS region_label,
  SUM(amount) AS total
FROM sales
GROUP BY region WITH ROLLUP;
+--------------+-------+
| region_label | total |
+--------------+-------+
| 未設定       |   400 |
| 東日本       |  2500 |
| 西日本       |  1900 |
| 総計         |  4800 |
+--------------+-------+

COALESCEだけでラベルを付けると、総計行まで「未設定」になってしまいます。判定の順番は「GROUPING()が先、COALESCEが後」です。ほかにも、次のような点でつまずきがちです。

症状 原因 対処
WHERE region IS NULL で総計行が取れない 小計・総計のNULLは集計の最後に付くため、WHEREの時点では存在しない HAVINGとGROUPING()で絞る
LIMITで件数が合わない LIMITはWITH ROLLUPの後に効き、小計行も1件と数える 件数を絞るなら、派生テーブルで明細を先に絞る
GROUP BYにない列でエラーになる ONLY_FULL_GROUP_BYが有効(MySQL 8.0の既定) GROUP BYに含めるか、集約関数かANY_VALUE()で包む
ORDER BYやDISTINCTを付けるとエラー MySQL 8.0.11以前の制限 8.0.12以降に上げるか、派生テーブルで包む

方言の違いにも触れておきます。PostgreSQLやSQL Serverでは、標準SQLの GROUP BY ROLLUP(region, category) という書き方を使います。CUBEやGROUPING SETSといった、より柔軟な集計も可能です。MySQLでは WITH ROLLUP の形で書くと覚えておきましょう。

まとめ

  • GROUP BYにWITH ROLLUPを付けると、小計・総計の行が足される
  • 小計は右の列から順に外して作られるので、列は「大きい区分を左」に書く
  • GROUPING()で小計・総計の行を判定し、ラベル付けやHAVINGでの絞り込みに使う
  • 元データのNULLとは、GROUPING()とCOALESCEを組み合わせて見分ける

集計の基本に不安があれば、GROUP BYとHAVINGの記事もあわせて読んでみてください。

参考リンク

アイキャッチ画像: Photo by Team Nocoloco on Unsplash

タイトルとURLをコピーしました