#24│再帰CTEでカレンダーシーケンスと階層構造を生成するSQL


「存在しない日付」と「深い階層」をSQLで扱う

通販の分析でよくぶつかる2つの問題がある。

問題1:売上がない日のデータが存在しない

日次売上を集計すると「注文があった日」しか行が出ない。定休日・障害日など注文がゼロだった日は結果から消える。これでは「この期間の毎日の売上推移」グラフが欠けてしまう。

問題2:カテゴリの親子階層を再帰的に展開できない

「コスメ > スキンケア > 化粧水」のような多段階の階層構造を持つカテゴリマスタがある。「コスメカテゴリ以下のすべての子孫カテゴリに属する商品を取得する」というクエリは、階層の深さが可変だと通常の JOIN では書けない。

どちらも解決するのが 再帰 CTEWITH 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. ベースケース :再帰の出発点。最初の1行(または数行)を返す
  2. 再帰ステップ :ベースケースや前回の結果を使って次の行を生成する
  3. 終了条件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_dateorder_counttotal_salesday_of_weekis_weekend
2024-01-01000
2024-01-02000
2024-01-034125,356,0000

元日・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_startmonth_endyear_nummonth_num
2023-01-012023-01-3120231
2023-02-012023-02-2820232
2024-12-012024-12-31202412

これで出てきた「欠損月の補完」に使うと、売上ゼロの月もゼロ行として正しく表現できる。


Part 2:階層構造の展開

STEP 4 ― カテゴリ階層を再帰的に展開する

通販の商品カテゴリはしばしば多段階の親子構造を持つ。

-- categories テーブル(カテゴリ階層マスタ)
-- category_id   : カテゴリID
-- category_name : カテゴリ名
-- parent_id     : 親カテゴリのID(ルートカテゴリはNULL)
category_idcategory_nameparent_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_idcategory_namedepthpath
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_idcategory_namedepthfull_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 NULLdepth < 上限値

どちらも「ベースケース UNION ALL 再帰ステップ」という構造は変わらない。終了条件を忘れずに書くこと、深さ制限を安全弁として入れることがポイントだ。

再帰CTEを使いこなすと「データが存在しない行を生成する」「深さ可変の階層を辿る」という、通常の SELECT では不可能な操作が SQL だけで完結する。日付欠損問題や階層カテゴリの取り扱いに悩んでいた分析担当者にとって、最も実感値の高いテクニックの一つ。


MarTech Farmをもっと見る

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

続きを読む