ガイド
連続した期間・グループを検出する (Gaps and Islands)
連続ログイン日数や欠番の検出など、連続する区間を見つけたいとき。
問題 — 連続した並びをグループ化したい
以下のような要件はすべて同じ形をしています:
- 「各ユーザーの連続ログイン日数」
- 「ステータスが `active` のまま続いた期間を1まとまりとして抽出」
- 「番号列に抜けている範囲(gaps)を探す」
これらは古典的なGaps and Islands問題と呼ばれ、ROW_NUMBER の差分トリックで鮮やかに解けます。
核心のトリック — ROW_NUMBER の差分
日付順に ROW_NUMBER を振り、もう一つ日付そのものから引くことで、同じ島(連続期間)の行は差が同じ値になります。この差を GROUP BY すれば島ごとにまとまる、という発想。
日付の場合は「日付 - 行番号日」が島ごとに定数、数値列なら「値 - 行番号」が島ごとに定数。
sql
-- ユーザーのログイン日から連続日数を計算
SELECT
user_id,
MIN(login_date) AS start_date,
MAX(login_date) AS end_date,
COUNT(*) AS streak_days
FROM (
SELECT
user_id,
login_date,
login_date - (ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY login_date
))::INT * INTERVAL '1 day' AS grp
FROM logins
) t
GROUP BY user_id, grp
ORDER BY user_id, start_date;応用: 状態が続いた期間
「ステータスが同じ間はひとつの期間」にしたい場合も同じ発想。ステータス変化を検知するために LAG() で前行と比較し、変わったタイミングに1を立てて SUM() OVER() で累計すると、その累計値が島番号になります。
sql
-- 同じステータスが続く期間をグルーピング
SELECT
machine_id, status,
MIN(ts) AS start_ts, MAX(ts) AS end_ts
FROM (
SELECT
machine_id, status, ts,
SUM(is_change) OVER (
PARTITION BY machine_id ORDER BY ts
) AS grp
FROM (
SELECT
machine_id, status, ts,
CASE WHEN status <> LAG(status) OVER (
PARTITION BY machine_id ORDER BY ts
) THEN 1 ELSE 0 END AS is_change
FROM machine_status
) t
) u
GROUP BY machine_id, status, grp;Gaps (欠番) の検出
連番の抜けている範囲を探したい場合は LEAD() で次の行との差を見て、差が 1 より大きいところが gap の開始。
sql
SELECT
id + 1 AS gap_start,
next_id - 1 AS gap_end
FROM (
SELECT id, LEAD(id) OVER (ORDER BY id) AS next_id
FROM tickets
) t
WHERE next_id - id > 1;注意点
日付に重複(同日2回ログイン)がある場合、ROW_NUMBER の差分トリックは破綻します。先に DISTINCT や GROUP BY で日付をユニーク化してから適用すること。
また、この手法は RDBMS を問わず動きますが、MySQL は 8.0 からしかウィンドウ関数がないので、5.7 以前では変数トリックによる代替が必要です。
