「存在しない日付」と「深い階層」をSQLで扱う
通販の分析でよくぶつかる2つの問題がある。
問題1:売上がない日のデータが存在しない
日次売上を集計すると「注文があった日」しか行が出ない。定休日・障害日など注文がゼロだった日は結果から消える。これでは「この期間の毎日の売上推移」グラフが欠けてしまう。
問題2:カテゴリの親子階層を再帰的に展開できない
「コスメ > スキンケア > 化粧水」のような多段階の階層構造を持つカテゴリマスタがある。「コスメカテゴリ以下のすべての子孫カテゴリに属する商品を取得する」というクエリは、階層の深さが可変だと通常の JOIN では書けない。
どちらも解決するのが 再帰 CTE(WITH RECURSIVE)だ。再帰CTEは「自分自身を参照するCTE」で、繰り返し処理をSQLで表現できる。
再帰 CTE の基本構造
WITH RECURSIVE cte_name AS (
-- ベースケース(再帰の起点・1回目の結果)
SELECT ...
UNION ALL
-- 再帰ステップ(前の結果を参照して次の結果を生成)
SELECT ...
FROM cte_name -- 自分自身を参照
WHERE ... -- 終了条件(これがないと無限ループ)
)
SELECT * FROM cte_name;3つのパーツで構成される。
- ベースケース :再帰の出発点。最初の1行(または数行)を返す
- 再帰ステップ :ベースケースや前回の結果を使って次の行を生成する
- 終了条件 :
WHERE句で「これ以上再帰しない」条件を指定する
終了条件を忘れると無限ループになる。Presto には最大再帰回数の上限(デフォルト10,000回)があるため、無限ループにはならないが、上限まで計算してエラーになる。
Part 1:カレンダーシーケンスの生成
STEP 1 ― 日付のシーケンスを生成する
「2024年1月1日から12月31日まで、1日ずつ増やした365行」を再帰CTEで生成する。
-- STEP1: 日付シーケンスを再帰CTEで生成する
WITH RECURSIVE date_series AS (
-- ベースケース:開始日
SELECT DATE '2024-01-01' AS dt
UNION ALL
-- 再帰ステップ:1日ずつ増やす
SELECT DATE_ADD('day', 1, dt)
FROM date_series
WHERE dt < DATE '2024-12-31' -- 終了条件:年末で止まる
)
SELECT dt
FROM date_series
ORDER BY dt;
出力イメージ(一部)
| dt |
|---|
| 2024-01-01 |
| 2024-01-02 |
| 2024-01-03 |
| … |
| 2024-12-31 |
365行が生成された。これがカレンダーテーブルの基本だ。
Presto での注意点
Presto(Treasure Data)ではWITH RECURSIVEが使える。ただし一部の古いバージョンではRECURSIVEキーワードが不要でWITHのみで動く場合もある。動作確認してから使おう。
PrestoにはSEQUENCE関数もあり、UNNEST(SEQUENCE(DATE '2024-01-01', DATE '2024-12-31', INTERVAL '1' DAY))でも同様の連続日付を生成できる。エンジンの対応状況で使い分ける。
STEP 2 ― カレンダーと売上をLEFT JOINして欠損日を補完する
「売上がない日もゼロとして表示する」ためにカレンダーシーケンスと売上テーブルをLEFT JOINする。
-- STEP2: 欠損日を0として補完した日次売上
WITH RECURSIVE date_series AS (
SELECT DATE '2024-01-01' AS dt
UNION ALL
SELECT DATE_ADD('day', 1, dt)
FROM date_series
WHERE dt < DATE '2024-12-31'
),
daily_sales AS (
SELECT
order_date,
COUNT(order_id) AS order_count,
SUM(total_amount) AS total_sales
FROM orders
WHERE
status = 'completed'
AND order_date BETWEEN DATE '2024-01-01' AND DATE '2024-12-31'
GROUP BY order_date
)
SELECT
ds.dt AS sales_date,
-- 売上がない日はNULLになるのでCOALESCEで0に補完
COALESCE(da.order_count, 0) AS order_count,
COALESCE(da.total_sales, 0) AS total_sales,
-- 曜日情報も付与
CASE EXTRACT(DOW FROM ds.dt)
WHEN 1 THEN '月' WHEN 2 THEN '火' WHEN 3 THEN '水'
WHEN 4 THEN '木' WHEN 5 THEN '金' WHEN 6 THEN '土'
WHEN 7 THEN '日'
END AS day_of_week,
-- 土日フラグ
CASE WHEN EXTRACT(DOW FROM ds.dt) IN (6, 7) THEN 1 ELSE 0 END AS is_weekend
FROM date_series ds
LEFT JOIN daily_sales da ON ds.dt = da.order_date
ORDER BY ds.dt;
出力イメージ(一部)
| sales_date | order_count | total_sales | day_of_week | is_weekend |
|---|---|---|---|---|
| 2024-01-01 | 0 | 0 | 月 | 0 |
| 2024-01-02 | 0 | 0 | 火 | 0 |
| 2024-01-03 | 412 | 5,356,000 | 水 | 0 |
| … | … | … | … | … |
元日・2日は注文ゼロで、3日から注文が始まっている様子が正確に表現された。
STEP 3 ― 月次カレンダーの生成(月初の日付シーケンス)
月次分析用に「月の初日だけ」の連続シーケンスを作る。
-- STEP3: 月次カレンダーシーケンス
WITH RECURSIVE month_series AS (
SELECT DATE_TRUNC('month', DATE '2023-01-01') AS month_start
UNION ALL
SELECT DATE_ADD('month', 1, month_start)
FROM month_series
WHERE month_start < DATE_TRUNC('month', DATE '2024-12-01')
)
SELECT
month_start,
DATE_ADD('day', -1, DATE_ADD('month', 1, month_start)) AS month_end, -- 月末日
EXTRACT(YEAR FROM month_start) AS year_num,
EXTRACT(MONTH FROM month_start) AS month_num
FROM month_series
ORDER BY month_start;出力イメージ(一部)
| month_start | month_end | year_num | month_num |
|---|---|---|---|
| 2023-01-01 | 2023-01-31 | 2023 | 1 |
| 2023-02-01 | 2023-02-28 | 2023 | 2 |
| … | … | … | … |
| 2024-12-01 | 2024-12-31 | 2024 | 12 |
これで出てきた「欠損月の補完」に使うと、売上ゼロの月もゼロ行として正しく表現できる。
Part 2:階層構造の展開
STEP 4 ― カテゴリ階層を再帰的に展開する
通販の商品カテゴリはしばしば多段階の親子構造を持つ。
-- categories テーブル(カテゴリ階層マスタ)
-- category_id : カテゴリID
-- category_name : カテゴリ名
-- parent_id : 親カテゴリのID(ルートカテゴリはNULL)| category_id | category_name | parent_id |
|---|---|---|
| C001 | コスメ | NULL |
| C002 | スキンケア | C001 |
| C003 | ヘアケア | C001 |
| C004 | 化粧水 | C002 |
| C005 | 美容液 | C002 |
| C006 | シャンプー | C003 |
| C007 | コンディショナー | C003 |
| C008 | ボディケア | NULL |
| C009 | ボディローション | C008 |
この階層を「コスメ(C001)以下のすべての子孫を取得する」ためにはどう書くか。parent_id = 'C001' だけでは直接の子(スキンケア・ヘアケア)しか取れない。孫(化粧水・美容液・シャンプー・コンディショナー)まで取るには再帰が必要。
-- STEP4: カテゴリ階層を再帰的に展開する
WITH RECURSIVE category_tree AS (
-- ベースケース:起点となるカテゴリ(今回はコスメ C001)
SELECT
category_id,
category_name,
parent_id,
0 AS depth, -- 深さ(起点は0)
category_name AS path -- 階層パス
FROM categories
WHERE category_id = 'C001' -- 展開したいルートカテゴリ
UNION ALL
-- 再帰ステップ:親が前回の結果にある子カテゴリを追加
SELECT
c.category_id,
c.category_name,
c.parent_id,
ct.depth + 1 AS depth,
ct.path || ' > ' || c.category_name AS path
FROM categories c
JOIN category_tree ct ON c.parent_id = ct.category_id
)
SELECT
category_id,
category_name,
parent_id,
depth,
path
FROM category_tree
ORDER BY path;出力イメージ
| category_id | category_name | depth | path |
|---|---|---|---|
| C001 | コスメ | 0 | コスメ |
| C003 | ヘアケア | 1 | コスメ > ヘアケア |
| C007 | コンディショナー | 2 | コスメ > ヘアケア > コンディショナー |
| C006 | シャンプー | 2 | コスメ > ヘアケア > シャンプー |
| C002 | スキンケア | 1 | コスメ > スキンケア |
| C005 | 美容液 | 2 | コスメ > スキンケア > 美容液 |
| C004 | 化粧水 | 2 | コスメ > スキンケア > 化粧水 |
path カラムで階層構造が視覚的に分かる。depth カラムを使えば「第何層目のカテゴリか」も把握できる。
STEP 5 ― 階層展開を使って「コスメ以下のすべての商品」を取得する(完成版)
再帰CTEで得た子孫カテゴリIDの一覧を使って、商品テーブルをフィルタする。
-- STEP5: コスメカテゴリ以下のすべての商品を取得する
WITH RECURSIVE category_tree AS (
SELECT category_id, category_name, parent_id, 0 AS depth
FROM categories
WHERE category_id = 'C001' -- コスメが起点
UNION ALL
SELECT c.category_id, c.category_name, c.parent_id, ct.depth + 1
FROM categories c
JOIN category_tree ct ON c.parent_id = ct.category_id
),
-- コスメ以下のすべてのカテゴリIDを取得
cosme_category_ids AS (
SELECT category_id FROM category_tree
)
SELECT
p.product_id,
p.product_name,
p.category_id,
c.category_name,
ct.depth,
p.price
FROM products p
JOIN categories c ON p.category_id = c.category_id
JOIN cosme_category_ids ci ON p.category_id = ci.category_id
JOIN category_tree ct ON p.category_id = ct.category_id
ORDER BY ct.depth, c.category_name, p.product_name;階層の深さにかかわらず、再帰CTEが自動的にすべての子孫カテゴリを展開してくれる。カテゴリが4階層になっても5階層になっても、クエリを変更する必要がない。
STEP 6 ― ルートからのすべての階層パスを一度に展開する
特定のルートに限定せず、すべてのルートカテゴリからの階層を一度に展開する。
-- STEP6: 全カテゴリの階層パスを一括展開
WITH RECURSIVE category_tree AS (
-- ベースケース:すべてのルートカテゴリ(parent_id IS NULL)
SELECT
category_id,
category_name,
parent_id,
0 AS depth,
CAST(category_id AS VARCHAR) AS id_path, -- IDの経路(ループ検知に使える)
category_name AS name_path
FROM categories
WHERE parent_id IS NULL -- ルートカテゴリ
UNION ALL
SELECT
c.category_id,
c.category_name,
c.parent_id,
ct.depth + 1,
ct.id_path || '/' || c.category_id,
ct.name_path || ' > ' || c.category_name
FROM categories c
JOIN category_tree ct ON c.parent_id = ct.category_id
-- 安全のため深さの上限を設ける
WHERE ct.depth < 10
)
SELECT
category_id,
category_name,
depth,
name_path AS full_path
FROM category_tree
ORDER BY full_path;出力イメージ
| category_id | category_name | depth | full_path |
|---|---|---|---|
| C008 | ボディケア | 0 | ボディケア |
| C009 | ボディローション | 1 | ボディケア > ボディローション |
| C001 | コスメ | 0 | コスメ |
| C003 | ヘアケア | 1 | コスメ > ヘアケア |
| C007 | コンディショナー | 2 | コスメ > ヘアケア > コンディショナー |
| C006 | シャンプー | 2 | コスメ > ヘアケア > シャンプー |
| C002 | スキンケア | 1 | コスメ > スキンケア |
| C004 | 化粧水 | 2 | コスメ > スキンケア > 化粧水 |
| C005 | 美容液 | 2 | コスメ > スキンケア > 美容液 |
full_path の文字列でソートすることで、階層が視覚的に整然と並ぶ。
無限ループを防ぐ2つの安全策
再帰CTEで最も気をつけるべきは無限ループ(循環参照)だ。データに「AはBの親、BはAの親」というサイクルが誤って入っていると、再帰が終わらなくなる。
安全策1:WHERE ct.depth < 上限値
WHERE ct.depth < 10 -- 10階層を超えたら再帰を止める実務上、カテゴリ階層が10段を超えることはほぼない。安全弁として必ず入れておく。
安全策2:id_path で循環を検知する
-- 自分のIDが経路にすでに含まれていれば循環 → 再帰を止める
WHERE ct.id_path NOT LIKE '%' || c.category_id || '%'ただしこの方法は id_path が長くなるほどパフォーマンスに影響する。Presto では深さ制限で十分なことが多い。
実務での運用ヒント
① Presto での SEQUENCE 関数との使い分け
日付シーケンスに限れば UNNEST(SEQUENCE(start_date, end_date, INTERVAL '1' DAY)) のほうがシンプルだ。ただし SEQUENCE は Presto 特有の関数で他の SQL エンジンに移植できない。チームで Presto に特化した開発をしているなら SEQUENCE、汎用性を重視するなら再帰CTE を選ぶ。
② 再帰CTEのパフォーマンス
再帰CTEは繰り返し実行されるため、階層が深いほどコストが高くなる。カテゴリ階層が数千行程度なら問題ないが、数十万行の階層(組織図など)に対する再帰は重くなる場合がある。その場合はあらかじめ階層を展開した「フラット化テーブル」を事前に計算・保存する設計が有効だ。
③ No.2(購買間隔分析)との組み合わせ
No.2 で「欠損月をカレンダーCTEで補完する」という運用ヒントを書いた。今回の date_series CTEがまさにその実装だ。月次売上がゼロの月も COALESCE(sales, 0) でゼロとして扱えば、LAGで前月比を計算するときに「12行前」のズレが発生しなくなる。
まとめ
再帰CTEの2つの用途を整理する。
| 用途 | ベースケース | 再帰ステップの終了条件 |
|---|---|---|
| 日付シーケンス | 開始日 | dt < 終了日 |
| 階層展開 | ルートカテゴリ(parent_id IS NULL) | depth < 上限値 |
どちらも「ベースケース UNION ALL 再帰ステップ」という構造は変わらない。終了条件を忘れずに書くこと、深さ制限を安全弁として入れることがポイントだ。
再帰CTEを使いこなすと「データが存在しない行を生成する」「深さ可変の階層を辿る」という、通常の SELECT では不可能な操作が SQL だけで完結する。日付欠損問題や階層カテゴリの取り扱いに悩んでいた分析担当者にとって、最も実感値の高いテクニックの一つ。