ファネル分析 — シーケンシャル変換率を MIN(...) FILTER + 順序比較で算出する
イベント分析の中核となるファネル分析は「view → cart → purchase のような複数ステップを順番に通過したユーザー数」を測定します。各ユーザーの各イベント初回時刻を MIN(event_time) FILTER (WHERE event_type='...') で抜き出し、時刻の前後関係でステップ通過を判定するのが定番パターンです。
-- 各ユーザーごとに、各イベントタイプの最初の発生時刻を取得 MIN(event_time) FILTER (WHERE event_type = 'view') AS t_view MIN(event_time) FILTER (WHERE event_type = 'cart') AS t_cart MIN(event_time) FILTER (WHERE event_type = 'purchase') AS t_purchase -- 順序比較: t_cart > t_view なら「view してから cart した」を確認 WHERE t_cart > t_view AND t_purchase > t_cart
NULL > 任意の値 は NULL を返し、COUNT(*) FILTER は NULL の行を除外します。これにより、cart イベントを発火していないユーザー(t_cart が NULL)は自動的にステップ2の集計から除外されます。ユーザー側で IS NOT NULL チェックを書く必要がない点が SQL の三値論理の利点です。EC サイトの user_events テーブルから、シーケンシャル(順序保証つき)ファネルを集計してください。①各ユーザーが view → cart → purchase の順序で各ステップを通過したかを判定、②全ステップ合計人数と、③ view→cart 変換率・ cart→purchase 変換率をパーセント(小数点1位)で算出してください。出力は1行5列です。
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | cart | 2024-01-10 10:05 |
| 1 | purchase | 2024-01-10 10:30 |
| 2 | view | 2024-01-10 11:00 |
| 2 | cart | 2024-01-10 11:15 |
| 2 | purchase | 2024-01-10 11:45 |
| 3 | view | 2024-01-10 12:00 |
| 3 | cart | 2024-01-10 12:30 |
| 4 | view | 2024-01-10 14:00 |
| 5 | view | 2024-01-10 15:00 |
| 5 | purchase | 2024-01-10 15:30 |
| step1_view | step2_cart | step3_purchase | view_to_cart_pct | cart_to_purchase_pct |
|---|---|---|---|---|
| 5 | 3 | 2 | 60.0 | 66.7 |
WITH user_first_steps AS ( -- FROM: ユーザーイベントテーブルからデータを取得 -- GROUP BY: ユーザー単位に集約する SELECT user_id, MIN(event_time) FILTER (WHERE event_type = 'view') AS t_view, -- MIN() FILTER: 各ユーザーの指定した条件(event_type)を満たす一番古い時間(MIN)を取得 MIN(event_time) FILTER (WHERE event_type = 'cart') AS t_cart, MIN(event_time) FILTER (WHERE event_type = 'purchase') AS t_purchase FROM user_events GROUP BY user_id ) SELECT COUNT(*) FILTER (WHERE t_view IS NOT NULL) AS step1_view, -- 条件に合う行数(IS NOT NULL=値あり) COUNT(*) FILTER (WHERE t_cart > t_view) AS step2_cart, -- 順序比較: カート時間がビュー時間より後か判定 COUNT(*) FILTER (WHERE t_purchase > t_cart AND t_cart > t_view) AS step3_purchase, -- 順序比較: さらに購入時間がカート時間より後か判定 ROUND(100.0 * COUNT(*) FILTER (WHERE t_cart > t_view) -- ROUND(): 指定した桁数で四捨五入する(ここでは小数点1位)/100.0を掛けることで、整数除算(小数点以下切り捨て)を回避し、小数計算(FLOAT)にする/NULLIF(値1, 値2): 値1が値2と等しい場合NULLを返す。ゼロ除算(0で割るエラー)を防ぐための定石 / NULLIF(COUNT(*) FILTER (WHERE t_view IS NOT NULL), 0), 1) AS view_to_cart_pct, ROUND(100.0 * COUNT(*) FILTER (WHERE t_purchase > t_cart AND t_cart > t_view) / NULLIF(COUNT(*) FILTER (WHERE t_cart > t_view), 0), 1) AS cart_to_purchase_pct FROM user_first_steps; /* 実行順序(SQLの論理的な評価順): 1. CTE user_first_steps → 各ユーザーの初回イベント時刻を集計 2. 外側クエリ → ステップ通過人数を条件カウントし変換率を算出 */
LEGEND
① FROM
FROM user_eventsユーザーごとに複数のイベント行があります。user5 は view → purchase で cart をスキップしている点に注意。| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 10:00 |
| 1 | cart | 10:05 |
| 1 | purchase | 10:30 |
| 2 | view | 11:00 |
| 2 | cart | 11:15 |
| 2 | purchase | 11:45 |
| 3 | view | 12:00 |
| 3 | cart | 12:30 |
| 4 | view | 14:00 |
| 5 | view | 15:00 |
| 5 | purchase | 15:30 |
t_cart > t_view は NULL となり、COUNT(*) FILTER は NULL の行を除外します。明示的に IS NOT NULL チェックを書かなくても自動的に正しく動作するのが SQL の美しさです。x / NULLIF(0, 0) = x / NULL = NULL となるため、0で割るエラーを回避できます。「データがまだない期間の変換率」も NULL として綺麗に表示され、後続 BI ツールでも誤った 0% 表示にならない実務的利点があります。COUNT(DISTINCT user_id) FILTER (WHERE event_type='cart') としてしまうと、user5 のように「view せず cart したユーザー(外部リンク経由など)」もカウントされ、シーケンシャルファネルではなく「累計達成者数」になります。実務では用途を明示して区別してください。3 / 5 = 0(PostgreSQL の整数除算)になります。必ず 100.0 * など片方を float にして算術を切り替えてください。::numeric や * 1.0 でも同様の効果が得られます。t_cart > t_view AND t_cart <= t_view + INTERVAL '24 hours' に置き換えるだけで実装できます。リテンション分析・キャンペーン効果測定・広告 ROI 計算で同じパターンが繰り返し使えるため、この MIN(...) FILTER + 順序比較パターンはイベント分析の中核技術です。セッションID付与 — LAG + SUM() OVER で累積セッション番号を生成(ギャップ&アイランド)
セッション境界の検出(基礎編で学習)の次の実務ステップはセッションIDの付与です。「セッション開始フラグ」を SUM() OVER (... ROWS UNBOUNDED PRECEDING) で累積和すると、各セッション内のすべての行に同じ番号が振られます。これが SQL の Gaps & Islands パターンの代表的応用です。
-- 累積セッション番号の生成 SUM(is_new_session) OVER ( PARTITION BY user_id ORDER BY event_time ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS session_num -- ↑ is_new_session が [1,0,1,0] なら 累積和 → [1,1,2,2] -- 同じセッション内の行は同じ番号になる
SUM(is_new_session) OVER (... ORDER BY ...) だけでも デフォルトで UNBOUNDED PRECEDING ~ CURRENT ROW(実は RANGE 単位)になりますが、ROWS と明示する方が安全で意図が明確です。ウィンドウ集計でのこの「枠指定」を理解することがウィンドウ関数熟達への鍵です。user_events テーブルに対して、30分以上のイベント間隔を新セッションの境界とみなし、各イベント行に user_id-session_num 形式のセッションIDを付与してください。出力列は user_id, session_id, event_type, event_time、user_id / event_time 昇順で返してください。
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | click | 2024-01-10 10:05 |
| 1 | view | 2024-01-10 10:50 |
| 1 | purchase | 2024-01-10 10:55 |
| 2 | view | 2024-01-10 11:00 |
| 2 | click | 2024-01-10 11:10 |
| 2 | view | 2024-01-10 12:00 |
| 3 | view | 2024-01-10 09:00 |
| user_id | session_id | event_type | event_time |
|---|---|---|---|
| 1 | 1-1 | view | 10:00 |
| 1 | 1-1 | click | 10:05 |
| 1 | 1-2 | view | 10:50 |
| 1 | 1-2 | purchase | 10:55 |
| 2 | 2-1 | view | 11:00 |
| 2 | 2-1 | click | 11:10 |
| 2 | 2-2 | view | 12:00 |
| 3 | 3-1 | view | 09:00 |
WITH with_lag AS ( -- LAG() OVER: 指定した並び順(ORDER BY)での前の行の値を取得するウィンドウ関数 -- PARTITION BY: ユーザーごとに計算を独立させる(ユーザー境界をまたがない) SELECT user_id, event_type, event_time, LAG(event_time) OVER ( PARTITION BY user_id ORDER BY event_time ) AS prev_event_time FROM user_events ), with_flag AS ( -- CASE WHEN ... THEN ... ELSE ... END: 条件分岐の基礎文法 -- EXTRACT(EPOCH FROM ...): 時間差を秒単位の数値(エポック秒)に変換する SELECT user_id, event_type, event_time, CASE WHEN prev_event_time IS NULL OR EXTRACT(EPOCH FROM (event_time - prev_event_time)) / 60 >= 30 THEN 1 ELSE 0 END AS is_new_session FROM with_lag ), with_session_num AS ( -- SUM() OVER: 条件に合致する値(ここではセッション開始フラグ1)の累積和を計算する -- ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: 「最初の行から現在の行まで」という計算枠の指定 SELECT user_id, event_type, event_time, SUM(is_new_session) OVER ( -- フラグの累積和 = セッション番号 PARTITION BY user_id ORDER BY event_time ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS session_num FROM with_flag ) SELECT user_id, user_id || '-' || session_num AS session_id, -- || 演算子: 文字列を連結する(例: '1' || '-' || '1' -> '1-1') event_type, TO_CHAR(event_time, 'HH24:MI') AS event_time FROM with_session_num ORDER BY user_id, event_time; /* 実行順序(SQLの論理的な評価順): 1. CTE with_lag 2. CTE with_flag 3. CTE with_session_num 4. 外側クエリ */
LEGEND
① FROM
FROM user_events3ユーザーの計8件のイベントログ。これに対し「30分以上の空白=新セッション」のルールで連続イベントをグルーピングし、セッションIDを付与します。| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 10:00 |
| 1 | click | 10:05 |
| 1 | view | 10:50 |
| 1 | purchase | 10:55 |
| 2 | view | 11:00 |
| 2 | click | 11:10 |
| 2 | view | 12:00 |
| 3 | view | 09:00 |
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW は物理的な行数で枠を決めますが、RANGE は ORDER BY のキー値で枠を決めます。同じ event_time の行が複数あると ROWS は1行ずつ、RANGE は同じ時刻の全行をまとめて処理するため挙動が変わります。累積セッション番号には ROWS を明示するのが安全です。SELECT * FROM with_flag に差し替えるだけで検証可能。実務では1つの巨大なクエリより CTE 分割が保守性で勝ります。SUM(is_new_session) OVER (ORDER BY event_time) と書いてしまうと、user1 の累積和 が user2 に引き継がれて間違ったセッション番号になります。ユーザー単位で累積をリセットするため、必ず PARTITION BY user_id を明示してください。(user_id, session_num) の複合キーで GROUP BY してください。SELECT session_id, COUNT(*) AS events, MAX(event_time) - MIN(event_time) AS duration FROM with_session_num GROUP BY session_id のように。Google Analytics や Mixpanel が提供する標準指標の多くは、内部的にはこの「セッション境界の検出 → セッションID付与 → 集計」の流れで実装されています。1つのウィンドウ関数パターンの理解が、プロダクト分析の世界を一気に広げます。コホート別リテンション — 登録月別の Nヶ月後生存率を JOIN + DATE_TRUNC で算出する
コホート分析は「初回利用月(登録月)が同じユーザー群」をコホートとして、各月でどれだけ残存しているかを時系列で追う分析です。SaaS・モバイルアプリ・EC など、サブスクリプション型プロダクトの最重要指標である継続率(リテンション)はすべてこのパターンで算出されます。
-- 各ユーザーのコホート(初回月)を確定 SELECT user_id, DATE_TRUNC('month', MIN(event_time))::date AS cohort_month FROM user_events GROUP BY user_id -- コホートに対する各月の生存率 SELECT cohort_month, months_since_signup, active_users, FIRST_VALUE(active_users) OVER ( PARTITION BY cohort_month ORDER BY months_since_signup ) AS cohort_size -- 各コホートの初期人数
user_events テーブルから、各ユーザーの初回利用月(コホート)を確定し、コホートごとの月次生存ユーザー数とリテンション率(%)を算出してください。出力列は cohort_month, months_since_signup, active_users, cohort_size, retention_pct、cohort_month / months_since_signup 昇順で返してください。
| user_id | event_time |
|---|---|
| 1 | 2024-01-15 |
| 1 | 2024-02-10 |
| 1 | 2024-03-05 |
| 2 | 2024-01-20 |
| 2 | 2024-02-15 |
| 3 | 2024-01-25 |
| 4 | 2024-02-05 |
| 4 | 2024-03-12 |
| 5 | 2024-02-20 |
| 6 | 2024-03-15 |
| cohort_month | months_since_signup | active_users | cohort_size | retention_pct |
|---|---|---|---|---|
| 2024-01-01 | 0 | 3 | 3 | 100.0 |
| 2024-01-01 | 1 | 2 | 3 | 66.7 |
| 2024-01-01 | 2 | 1 | 3 | 33.3 |
| 2024-02-01 | 0 | 2 | 2 | 100.0 |
| 2024-02-01 | 1 | 1 | 2 | 50.0 |
| 2024-03-01 | 0 | 1 | 1 | 100.0 |
WITH user_cohort AS ( SELECT -- 各ユーザーの初回月 = コホート user_id, DATE_TRUNC('month', MIN(event_time))::date AS cohort_month -- 月に切り捨て→DATE 型 FROM user_events GROUP BY user_id ), user_activity AS ( SELECT DISTINCT -- 各ユーザーがアクティブだった月(重複排除) user_id, DATE_TRUNC('month', event_time)::date AS activity_month FROM user_events ), cohort_join AS ( SELECT -- 各活動行に経過月を付与 uc.cohort_month, ua.activity_month, ua.user_id, ((EXTRACT(YEAR FROM ua.activity_month) - EXTRACT(YEAR FROM uc.cohort_month)) * 12 -- 登録からの経過月数(年差×12+月差) + (EXTRACT(MONTH FROM ua.activity_month) - EXTRACT(MONTH FROM uc.cohort_month)))::int AS months_since_signup FROM user_cohort uc -- USING (user_id): 両テーブルで共通のカラム名(user_id)を使って JOIN する省略記法 JOIN user_activity ua USING (user_id) ) SELECT cohort_month, months_since_signup, COUNT(DISTINCT user_id) AS active_users, -- 各月のアクティブユーザー数 FIRST_VALUE(COUNT(DISTINCT user_id)) OVER ( -- FIRST_VALUE() OVER: 指定した並び順で「最初の行」の値を取得する/月の経過順に並べた最初の値=初期登録人数を取得 PARTITION BY cohort_month ORDER BY months_since_signup ) AS cohort_size, ROUND(100.0 * COUNT(DISTINCT user_id) -- アクティブ数 / 初期登録人数 で残存率を算出 / FIRST_VALUE(COUNT(DISTINCT user_id)) OVER ( PARTITION BY cohort_month ORDER BY months_since_signup ), 1) AS retention_pct FROM cohort_join GROUP BY cohort_month, months_since_signup ORDER BY cohort_month, months_since_signup; /* 実行順序(SQLの論理的な評価順): 1. CTE user_cohort 2. CTE user_activity 3. CTE cohort_join 4. 外側クエリ 5. 100.0 * active / cohort_size → リテンション率(%) */
LEGEND
① FROM
FROM user_events3ヶ月(1〜3月)にわたる6ユーザー10件のイベントログ。これを「初回月コホート」「月次活動」の2つに分解してから結合し、コホート分析のための2次元テーブルを構築します。| user_id | event_time |
|---|---|
| 1 | 2024-01-15 |
| 1 | 2024-02-10 |
| 1 | 2024-03-05 |
| 2 | 2024-01-20 |
| 2 | 2024-02-15 |
| 3 | 2024-01-25 |
| 4 | 2024-02-05 |
| 4 | 2024-03-12 |
| 5 | 2024-02-20 |
| 6 | 2024-03-15 |
FIRST_VALUE(active_users) OVER (PARTITION BY cohort_month ORDER BY months_since_signup) は、各コホートの月0の値(=コホート初期人数)を、同じコホートの全行にコピーします。割合計算のために「分母」を各行に持たせる定石パターンで、リテンション・シェア率・寄与度などあらゆる比率指標で使えます。(YYYY差 × 12) + 月差 という計算は、月単位の経過時間を整数で取得する標準的な手法です。AGE() 関数もありますが interval 型を返すため整数比較や ORDER BY で扱いにくい場合があり、EXTRACT による直接計算が実務で好まれます。COUNT(DISTINCT user_id) は正しく動作してもクエリが無駄に重くなります。JOIN 前に DISTINCT で正規化するのが鉄則です。(SELECT active_users FROM ... WHERE months_since_signup = 0) のような副問い合わせは1コホートずつ計算するため遅くなります。ウィンドウ関数なら 1パスで全コホートを処理でき、データが100万行を超える本番環境ではこの差が顕著です。行動パス集約 — STRING_AGG で頻出ユーザー行動パスを抽出
マーケティングや UX 改善において、「ユーザーがどの順序でイベントを経由したか」というパス情報は極めて重要です。例えば「view → cart → purchase」と「view → purchase」では同じ purchase でも背景が全く異なります。
本問題では STRING_AGG(または ARRAY_AGG)を用いた2段階集約を学びます。1段目でユーザー単位でイベントを時系列順に文字列化し、2段目で同一パスのユーザー数をカウントします。これにより「最も多いユーザー行動パターン Top N」を抽出できます。
-- ユーザーごとにイベントを時系列順に連結 STRING_AGG(event_type, ' → ' ORDER BY event_time)
各ユーザーがイベントを発火した順序を「 → 」で連結し、同一パスごとにユーザー数とパスの長さ(イベント数)を集計するクエリを書いてください。
集計の際は、ユーザー単位でイベントを時系列順に連結してパス文字列を生成したうえで、同一パスごとのユーザー数をカウントしてください。出力はユーザー数の多い順(同数の場合はパスが長い順)に並べます。
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-15 10:00:00 |
| 1 | cart | 2024-01-15 10:05:00 |
| 1 | purchase | 2024-01-15 10:10:00 |
| 2 | view | 2024-01-15 11:00:00 |
| 2 | cart | 2024-01-15 11:03:00 |
| 2 | purchase | 2024-01-15 11:08:00 |
| 3 | view | 2024-01-15 12:00:00 |
| 3 | cart | 2024-01-15 12:02:00 |
| 4 | view | 2024-01-15 13:00:00 |
| 4 | purchase | 2024-01-15 13:01:00 |
| 5 | view | 2024-01-15 14:00:00 |
| 5 | purchase | 2024-01-15 14:05:00 |
| path | user_count | path_length |
|---|---|---|
| view → cart → purchase | 2 | 3 |
| view → purchase | 2 | 2 |
| view → cart | 1 | 2 |
WITH user_paths AS ( -- 1段目: ユーザー単位でグループ化し、イベントを時系列に連結 SELECT user_id, STRING_AGG(event_type, ' → ' ORDER BY event_time) AS path, -- STRING_AGG(列, 区切り文字 ORDER BY 並び順): 順序を保証して文字列結合する関数 COUNT(*) AS path_length -- COUNT(*): そのユーザーが発生させたイベント総数をカウント FROM events GROUP BY user_id ) -- 2段目: 同一のパス(行動パターン)ごとにユーザー数を集計 SELECT path, COUNT(*) AS user_count, -- この COUNT(*) は GROUP BY path によって束ねられた行(=同一パスのユーザー)の数 MAX(path_length) AS path_length -- MAX(): グループ内の最大値。同一パスなら文字の長さは同じため MAX() や MIN() で取得可能 FROM user_paths GROUP BY path ORDER BY user_count DESC, path_length DESC; -- ORDER BY 列 DESC: 降順(大きいものから順)に並べ替え /* 実行順序(SQLの論理的な評価順): 1. CTE user_paths 2. 外側クエリ */
LEGEND
STEP 1
events テーブル5ユーザーの 12 行のイベントログ。これを user_id ごとにグループ化し、event_time の順にイベント名を連結する。| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 10:00:00 |
| 1 | cart | 10:05:00 |
| 1 | purchase | 10:10:00 |
| 2 | view | 11:00:00 |
| 2 | cart | 11:03:00 |
| 2 | purchase | 11:08:00 |
| 3 | view | 12:00:00 |
| 3 | cart | 12:02:00 |
| 4 | view | 13:00:00 |
| 4 | purchase | 13:01:00 |
| 5 | view | 14:00:00 |
| 5 | purchase | 14:05:00 |
STRING_AGG(event_type, ' → ' ORDER BY event_time) のように関数内に ORDER BY を書くことで、グループ内の集約順序を明示的に指定できます。これを忘れると「view → purchase → cart」など順序が崩れた結果が返り、パス分析として無意味になります。ARRAY_AGG でも同じ構文で配列を生成可能で、文字列より構造化された後続処理ができます。user_id でユーザーごとに行動を1行に圧縮、2段目は path でパターンごとに集計。この階層構造は、A/B テストの変化パターン分類、購入経路分析、画面遷移分析など、多くの実務シーンで応用できます。ARRAY_AGG(event_type ORDER BY event_time) で配列を取得し、array_length、arr[1](先頭要素)、arr && ARRAY['cart'](要素含有判定)など豊富な配列操作が可能です。文字列化は表示用、配列化は分析用と覚えておくと使い分けがスムーズです。STRING_AGG(event_type, ' → ') だけだと実行ごとに結果順序が変わる可能性があります。データベースによっては挿入順や物理順で偶然動くこともありますが、本番では必ず ORDER BY を引数内に明記してください。GROUP_CONCAT(event_type ORDER BY event_time SEPARATOR ' → ')、Oracle は LISTAGG(event_type, ' → ') WITHIN GROUP (ORDER BY event_time) と構文が異なります。移植時には方言マトリクスを必ず確認すべきポイントです。再帰CTE で日付の穴を埋める — イベントが無い日も 0 として可視化
日次の DAU(Daily Active Users)をグラフ化する際、「イベント発火がゼロだった日」がデータに存在しないと折れ線が不自然に途切れることがあります。これを防ぐには、分析対象期間の全日付を持つ「日付シリーズテーブル」を生成し、実データと LEFT JOIN するのが定石です。
本問題では WITH RECURSIVE(再帰共通テーブル式)を使って日付シリーズを動的に生成します。再帰CTE は SQL の中でも特に概念が掴みづらいため、本解説では「現在の累積結果」「今回追加された行」「次に生成される行」の3点を毎ステップで明示しながら、段階的に動作を可視化します。
-- WITH RECURSIVE の基本構造 WITH RECURSIVE date_series AS ( SELECT DATE '2023-09-01' AS day -- 基底(1行目) UNION ALL SELECT day + 1 -- 再帰部(前回の行に+1日) FROM date_series WHERE day < DATE '2023-09-10' -- 終了条件 )
分析期間 2024-01-10 から 2024-01-15 まで(両端含む6日間)について、日毎の DAU(重複排除したユーザー数)を集計し、イベント無しの日も 0 として含めて出力するクエリを書いてください。
events テーブル単体からの集計ではイベントの無い日が欠落するため、再帰CTEなどを活用して対象期間の連続した日付シリーズを生成し、集計結果と結合して欠損日を補完してください。
| user_id | event_time |
|---|---|
| 1 | 2024-01-10 09:00 |
| 2 | 2024-01-10 14:00 |
| 1 | 2024-01-12 10:00 |
| 3 | 2024-01-12 16:00 |
| 2 | 2024-01-14 11:00 |
| 1 | 2024-01-15 09:30 |
| day | active_users |
|---|---|
| 2024-01-10 | 2 |
| 2024-01-11 | 0 |
| 2024-01-12 | 2 |
| 2024-01-13 | 0 |
| 2024-01-14 | 1 |
| 2024-01-15 | 1 |
-- WITH RECURSIVE: 再帰的な処理を行う CTE の宣言(自己参照が可能になる) WITH RECURSIVE date_series AS ( -- 1段目: 基底(ベースケース) — 開始日を1行返す SELECT DATE '2024-01-10' AS day -- UNION ALL: 複数の SELECT 結果を重複を保持したまま縦に結合する UNION ALL -- 2段目: 再帰部 — 前回の結果(直近行)の day に対して1日を加算した行を返す SELECT day + 1 FROM date_series WHERE day < DATE '2024-01-15' -- 終了条件: day が 2024-01-15 より小さい間だけループを続ける ), daily_dau AS ( -- events テーブルの時間を日に丸めて日ごとの DAU(重複排除したユーザー数)を集計 SELECT DATE_TRUNC('day', event_time)::DATE AS day, COUNT(DISTINCT user_id) AS active_users -- DISTINCT user_id: 重複するユーザーIDを1回だけカウントする FROM events GROUP BY DATE_TRUNC('day', event_time)::DATE ) -- 日付シリーズを主軸に LEFT JOIN し、一致しない(データがない)日の NULL を 0 に変換 SELECT ds.day, COALESCE(dd.active_users, 0) AS active_users -- COALESCE(値1, 値2): 値1が NULL なら値2を返す関数(欠損値の穴埋めの定石) FROM date_series ds -- LEFT JOIN: 左側のテーブル(ds)の行をすべて残し、右側(dd)は条件(ON)に合致する行だけを結合 LEFT JOIN daily_dau dd ON ds.day = dd.day ORDER BY ds.day; /* 実行順序(SQLの論理的な評価順): 1. CTE date_series (WITH RECURSIVE) 2. CTE daily_dau 3. 外側クエリ */
LEGEND
STEP 1
events テーブル(スパース)6 件のイベントログ。01-11 と 01-13 にはイベントが無いため、このテーブルだけでは「ゼロ DAU の日」が表現できない。| user_id | event_time |
|---|---|
| 1 | 2024-01-10 09:00 |
| 2 | 2024-01-10 14:00 |
| 1 | 2024-01-12 10:00 |
| 3 | 2024-01-12 16:00 |
| 2 | 2024-01-14 11:00 |
| 1 | 2024-01-15 09:30 |
UNION ALL の左側(ベースケース)が1度だけ評価され、右側(再帰部)が「直前に追加された行」を入力に繰り返し評価されます。各反復で WHERE 句が偽になった時点で終了します。本問題のように「日付シリーズを生成する」「階層構造を辿る」「N以下のフィボナッチ数列を生成する」など、1行ずつ漸進的に積み上げる処理に最適です。generate_series('2024-01-10'::date, '2024-01-15'::date, INTERVAL '1 day') という PostgreSQL の専用関数もありますが、概念理解には WITH RECURSIVE が最も学習価値が高いです。WHERE day < '2024-01-15' がなければ無限に行を生成し、メモリ枯渇や DB クラッシュを引き起こします。必ず「有限回で偽になる条件」を WHERE に含めるのが鉄則。本問題では「直近追加された行」を入力にしているため、日付がインクリメントされ自然に終了します。多くの DB には安全装置として max_recursion のような上限設定もありますが、依存しない設計が望ましいです。JOIN(=INNER JOIN)と書くと、daily_dau に存在しない 01-11, 01-13 が結果から消え、穴埋め目的が果たせません。「全期間を保証したい側」を必ず LEFT JOIN の左に置くのが原則です。WHERE dd.active_users IS NOT NULL など、JOIN 後の右側カラムを WHERE で絞ると、せっかくの LEFT JOIN が事実上 INNER JOIN に退化し、穴が再び消えます。右側条件は ON 句に書くか、または FROM 句の中で先に絞り込んでおきます。WHERE 句を書き忘れたり、終了しない条件を書くと無限ループになります。初回の累積行が必ず WHERE 句を偽にできることを設計時点で確認するのが安全です。本番投入前に小さな範囲でテスト実行する習慣を。day, year, month, day_of_week, is_weekend, fiscal_quarter, ... など豊富なカラムを持つこのテーブルを、ほぼ全ての時系列クエリで JOIN 軸として使います。dbt(data build tool)のパッケージ dbt_date や dbt_utils.date_spine は内部でこの考え方を実装しています。再帰CTE の理解は「なぜ日付次元テーブルが必要か」を腹落ちさせるためにも極めて重要です。