SQL 日付・時刻 — 週次集計・セッション化・移動平均の応用

応用日付・時刻週の始まり期間の按分LAG / セッション化移動平均LATERAL 補完PostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

週次集計 — 週の始まりを日曜へずらして集計する

DATE_TRUNC週次集計週の始まりISO週
前提知識

DATE_TRUNC('week', ...) の週始まりは月曜固定で、変更するオプションはありません。日曜始まりにしたいときは、いったん1日ずらして丸め、同じ量を戻します。

SELECT (DATE_TRUNC('week', date_col + INTERVAL '1 day')
        - INTERVAL '1 day')::date AS week_start
FROM table_name;
-- 日曜を1日進めると月曜になり、月曜始まりの丸めで同じ週へ入る
ずらす向きを間違えない:日曜を週の先頭に含めたいので、日付を進めて丸め、結果を戻します。逆向きにすると、土曜始まりの週ができます。
問題

signups テーブルを日曜始まりの週で集計し、週の初日と登録件数を求めてください。取得列は week_start, signups、week_start 昇順で返してください。

使用テーブル
▸ signups
signup_idchannelsigned_up_on
1web2026-03-01
2web2026-03-02
3app2026-03-07
4app2026-03-08
5web2026-03-09
6web2026-03-14
7app2026-03-15
期待出力
week_startsignups
2026-03-013
2026-03-083
2026-03-151
模範解答コード
SELECT
  (DATE_TRUNC('week', signed_up_on + INTERVAL '1 day') - INTERVAL '1 day')::date AS week_start,  -- 進めて丸め、戻す
  COUNT(*) AS signups
FROM     signups
GROUP BY (DATE_TRUNC('week', signed_up_on + INTERVAL '1 day') - INTERVAL '1 day')::date
ORDER BY week_start;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM signups                 → 7行読み込み
  2. GROUP BY ずらして丸めた週初日   → 3グループに分割
  3. SELECT COUNT(*)              → グループごとに件数
  4. ORDER BY week_start          → 週初日の昇順
  */
解説(テーブル変化・ポイント)
SELECT (DATE_TRUNC('week', signed_up_on + INTERVAL '1 day') - INTERVAL '1 day')::date AS week_start, COUNT(*) AS signups FROM signups GROUP BY (DATE_TRUNC('week', signed_up_on + INTERVAL '1 day') - INTERVAL '1 day')::date ORDER BY week_start;
LEGEND
データ取得・読込対象
① FROM signups
FROM signupssignups 全7行を読み込みます。2026-03-01 と 2026-03-08、2026-03-15 はいずれも日曜で、週の切り方が結果を変える境目です。曜日はテーブルの列ではなく、境目を見やすくするための参考表示です。
1 / 6
signup_idchannelsigned_up_on曜日
1web2026-03-01
2web2026-03-02
3app2026-03-07
4app2026-03-08
5web2026-03-09
6web2026-03-14
7app2026-03-15
全 7行 読込
学習ポイント
ずらして丸めて戻す:週始まりを変える一般形は「オフセットぶん進める → 丸める → 同じだけ戻す」です。土曜始まりなら INTERVAL '2 days' と、両方の数値を揃えて書き換えます。
週初日を軸に持つ:週番号(EXTRACT(WEEK ...))ではなく日付を集計キーにすると、年をまたぐ並び替えがそのまま時系列順になります。表示用の「第N週」はレポート側で付けます。
境目の1日で数字が動く:同じデータでも、月曜始まりと日曜始まりでは週ごとの件数が変わります。前週比を出す指標では、週の定義が変わった時点で連続性が切れます。
アンチパターン
ISO週番号で日曜始まりを表そうとする:EXTRACT(WEEK ...) はISO 8601の週で、月曜始まり・木曜を含む週が第1週という規則が固定されています。年末年始には前年の第52週や翌年の第1週が混ざるため、日曜始まりの業務要件は表現できません。
曜日番号から手計算で週初を出す:signed_up_on - EXTRACT(DOW FROM signed_up_on) のような式は、整数と日付の演算規則に依存し読み手を選びます。丸めの規則は DATE_TRUNC に任せ、ずらす量だけを明示します。
実務コラム:週の定義は部署ごとに違う
小売では日曜始まり、製造やITの現場ではISO準拠の月曜始まり、会計では「4-4-5週」のように月内の週数を固定する暦が使われます。同じ社内でも部署ごとに週の意味が違うため、週次レポートは必ず定義を添えて配ります。運用が長くなるなら、日付ごとに週初日・会計月・期を持つカレンダーテーブルを用意し、全クエリがそこを参照する形にすると、定義の差が数字の差として現れなくなります。
QUESTION 2

期間の按分 — 契約期間と対象月の交差日数を求める

GREATEST / LEASTCOALESCE日割り計算半開区間
前提知識

2つの区間が交差する部分は、開始は遅い方終了は早い方で決まります。GREATESTLEAST を使うと、場合分けなしに交差区間の両端が書けます。

SELECT
  GREATEST(start_col, DATE '2025-01-01') AS from_date,  -- 遅い方の開始
  LEAST(end_col,      DATE '2025-02-01') AS to_date     -- 早い方の終了
FROM table_name;
終端は「次の期間の開始」で持つ:区間を [開始, 終了) の半開で扱うと、交差日数は 終了 - 開始 の引き算だけで求まります。+1 の補正は不要で、隣り合う期間を足しても重複しません。
問題

subscriptions テーブルの契約は [started_on, ended_on) の半開区間で、ended_on が NULL の行は継続中を表します。2026年5月に課金対象となる日数を契約ごとに求めてください。5月と1日も重ならない契約は除きます。取得列は sub_id, plan, billed_from, billed_to, billed_days、sub_id 昇順で返してください。billed_to は半開区間の終端(課金しない最初の日)とします。

使用テーブル
▸ subscriptions
sub_idplanstarted_onended_on
1basic2026-04-202026-05-10
2pro2026-05-052026-05-25
3pro2026-05-25NULL
4basic2026-03-012026-04-15
5basic2026-04-012026-06-10
期待出力
sub_idplanbilled_frombilled_tobilled_days
1basic2026-05-012026-05-109
2pro2026-05-052026-05-2520
3pro2026-05-252026-06-017
5basic2026-05-012026-06-0131
模範解答コード
SELECT
  sub_id,
  plan,
  GREATEST(started_on, DATE '2026-05-01') AS billed_from,   -- 月初と契約開始の遅い方
  LEAST(COALESCE(ended_on, DATE '2026-06-01'), DATE '2026-06-01') AS billed_to,  -- 継続中は翌月初とみなす
  LEAST(COALESCE(ended_on, DATE '2026-06-01'), DATE '2026-06-01')
    - GREATEST(started_on, DATE '2026-05-01') AS billed_days   -- 半開区間なので差がそのまま日数
FROM   subscriptions
WHERE  started_on < DATE '2026-06-01'                          -- 5月より後に始まる契約を除外
  AND  COALESCE(ended_on, DATE '2026-06-01') > DATE '2026-05-01'  -- 5月より前に終わる契約を除外
ORDER BY sub_id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM subscriptions           → 5行読み込み
  2. WHERE 5月と重なる契約に限定     → 4行に絞り込み
  3. SELECT GREATEST / LEAST      → 交差区間の両端を確定
  4. SELECT 終端 - 開始            → 課金日数を計算
  5. ORDER BY sub_id              → 契約IDの昇順
  */
解説(テーブル変化・ポイント)
SELECT sub_id, plan, GREATEST(started_on, DATE '2026-05-01') AS billed_from, LEAST(COALESCE(ended_on, DATE '2026-06-01'), DATE '2026-06-01') AS billed_to, LEAST(COALESCE(ended_on, DATE '2026-06-01'), DATE '2026-06-01') - GREATEST(started_on, DATE '2026-05-01') AS billed_days FROM subscriptions WHERE started_on < DATE '2026-06-01' AND COALESCE(ended_on, DATE '2026-06-01') > DATE '2026-05-01' ORDER BY sub_id;
LEGEND
データ取得・読込対象
① FROM subscriptions
FROM subscriptionssubscriptions 全5行を読み込みます。5月をまたぐ契約、5月に始まる契約、終了日が NULL の継続中の契約が混ざっています。
1 / 6
sub_idplanstarted_onended_on
1basic2026-04-202026-05-10
2pro2026-05-052026-05-25
3pro2026-05-25NULL
4basic2026-03-012026-04-15
5basic2026-04-012026-06-10
全 5行 読込
学習ポイント
交差は「遅い開始」と「早い終了」:包含・部分重複・完全一致のどれであっても、GREATESTLEAST の組で交差区間が決まります。CASE で契約の形を場合分けする必要はありません。
NULL は「無限の未来」に置き換える:継続中を表す NULL のままでは比較も引き算もできません。COALESCE(ended_on, 集計期間の終端) と読み替えることで、終了済みの契約と同じ式で扱えます。
半開区間なら日数は引き算だけ:終端を「課金しない最初の日」として持つと、終了 - 開始 がそのまま日数です。閉区間で持つと +1 の補正が必要になり、月をまたぐ合算で二重計上が起きやすくなります。
アンチパターン
契約の形ごとに CASE を書く:「月初より前に開始」「月内で終了」…と場合分けを並べると、分岐の抜けがそのまま金額の誤りになります。切り詰めは GREATEST / LEAST に任せます。
NULL を条件から素通しする:ended_on > DATE '2026-05-01' とだけ書くと、継続中の行は UNKNOWN で落ち、いま課金すべき契約が請求から消えます。NULL を含む列の比較は、置き換えるか IS NULL を明示します。
実務コラム:日割りの分母を決める
交差日数が出たら、次に決めるのは分母です。「その月の日数」で割ると月ごとに単価が変わり、「30日固定」で割ると2月だけ割高になります。年額を365で割る方式もあり、どれを採るかは料金規約の文言で決まります。SQLとしてはどれも同じ形で書けるため、迷ったら規約を先に確認します。返金や途中解約の計算でも同じ分母を使わないと、請求と返金で1日ぶんの差が残ります。
QUESTION 3

LAG とセッション化 — 無操作30分でイベント列を区切る

LAGウィンドウ関数セッション化累積和
前提知識

LAG で1つ前の行の時刻を引き寄せると、行と行の間隔が計算できます。間隔がしきい値以上の行に 1 を立て、その累積和を取ると連番の区切りになります。ウィンドウ関数どうしは入れ子にできないため、旗を立てる階層と累積和を取る階層は分けて書きます。

SELECT SUM(is_new) OVER (PARTITION BY key_col ORDER BY ts_col) AS grp
FROM (
  SELECT key_col, ts_col,
         CASE WHEN ts_col - LAG(ts_col) OVER (PARTITION BY key_col ORDER BY ts_col)
                   >= INTERVAL '30 minutes'
              THEN 1 ELSE 0 END AS is_new
  FROM   table_name) t;
各グループの先頭は NULL:LAG は最初の行で NULL を返し、比較結果も NULL(真ではない)になります。そのため先頭行の旗は 0 のままで、累積和は 0 から始まります。
問題

app_events テーブルのイベント列を、直前のイベントから30分以上空いたところで区切ってセッションにまとめてください。セッション番号はユーザーごとに 1 から始まる連番とします。取得列は user_id, session_no, started_at, ended_at, events、user_id・session_no の昇順で返してください。

使用テーブル
▸ app_events
event_iduser_idoccurred_at
11012026-08-10 09:00:00
21012026-08-10 09:12:00
31012026-08-10 10:05:00
41012026-08-10 10:20:00
51022026-08-10 09:30:00
61022026-08-10 11:00:00
期待出力
user_idsession_nostarted_atended_atevents
10112026-08-10 09:00:002026-08-10 09:12:002
10122026-08-10 10:05:002026-08-10 10:20:002
10212026-08-10 09:30:002026-08-10 09:30:001
10222026-08-10 11:00:002026-08-10 11:00:001
模範解答コード
WITH marked AS (
  SELECT
    event_id, user_id, occurred_at,
    CASE WHEN occurred_at - LAG(occurred_at) OVER (PARTITION BY user_id ORDER BY occurred_at)
              >= INTERVAL '30 minutes'
         THEN 1 ELSE 0 END AS is_new   -- 区切りに旗を立てる
  FROM app_events
), numbered AS (
  SELECT
    user_id, occurred_at,
    SUM(is_new) OVER (PARTITION BY user_id ORDER BY occurred_at) + 1 AS session_no  -- 旗の累積和が番号
  FROM marked
)
SELECT
  user_id,
  session_no,
  MIN(occurred_at) AS started_at,
  MAX(occurred_at) AS ended_at,
  COUNT(*)          AS events
FROM     numbered
GROUP BY user_id, session_no
ORDER BY user_id, session_no;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM app_events              → 6行読み込み
  2. LAG で直前の時刻を取得         → 間隔を計算し旗を立てる
  3. SUM(...) OVER で累積          → セッション番号を確定
  4. GROUP BY user_id, session_no → セッション単位に集約
  5. ORDER BY user_id, session_no → ユーザー・番号の昇順
  */
解説(テーブル変化・ポイント)
WITH marked AS ( SELECT event_id, user_id, occurred_at, CASE WHEN occurred_at - LAG(occurred_at) OVER (PARTITION BY user_id ORDER BY occurred_at) >= INTERVAL '30 minutes' THEN 1 ELSE 0 END AS is_new FROM app_events ), numbered AS ( SELECT user_id, occurred_at, SUM(is_new) OVER (PARTITION BY user_id ORDER BY occurred_at) + 1 AS session_no FROM marked ) SELECT user_id, session_no, MIN(occurred_at) AS started_at, MAX(occurred_at) AS ended_at, COUNT(*) AS events FROM numbered GROUP BY user_id, session_no ORDER BY user_id, session_no;
LEGEND
データ取得・読込対象
① FROM app_events
FROM app_eventsapp_events 全6行を読み込みます。ユーザー101が4件、ユーザー102が2件で、いずれも時刻順に並んでいます。
1 / 6
event_iduser_idoccurred_at
11012026-08-10 09:00:00
21012026-08-10 09:12:00
31012026-08-10 10:05:00
41012026-08-10 10:20:00
51022026-08-10 09:30:00
61022026-08-10 11:00:00
全 6行 読込
学習ポイント
旗を立てて累積する:「境界に1を立て、累積和でグループ番号にする」は、時系列を区切る定番の2段構えです。しきい値を変えるだけで、30分セッションでも24時間セッションでも同じ形が使えます。
比較は INTERVAL 同士で:TIMESTAMP - TIMESTAMP の結果は INTERVAL なので、>= INTERVAL '30 minutes' と型を揃えて比較します。秒数へ直したいときは EXTRACT(EPOCH FROM …) を使います。
PARTITION BY を忘れない:区切り判定も累積和も、ユーザー単位で閉じている必要があります。PARTITION BY user_id が無いと、別ユーザーの最後のイベントとの間隔で区切りが決まってしまいます。
アンチパターン
ウィンドウ関数の結果を同じ SELECT で再利用する:SUM(is_new) OVER …is_new を同一階層で定義することはできません。CTE かサブクエリで一段下げて、確定した列を参照します。
アプリ側でループして区切る:全イベントを取得してプログラムで分割すると、転送量が増えるうえ、しきい値の定義が画面・バッチ・分析基盤に散らばります。区切りの規則はクエリに1か所で書きます。
実務コラム:30分という数字はどこから来るか
ウェブ解析で広く使われる「無操作30分でセッション終了」は、計測ツールの既定値が事実上の標準になったものです。動画視聴のように操作間隔が空きやすいサービスでは短すぎ、決済フローのように短時間で完結する導線では長すぎます。しきい値はパラメータとして外に出し、変更したときに過去データも同じ定義で再計算できるようにしておきます。セッション数はKPIの分母になりやすいため、定義変更は前後比較の断絶として記録に残します。
QUESTION 4

移動平均のフレーム — 行数ではなく日数で7日間を切り出す

RANGE フレームウィンドウ関数移動平均欠測日
前提知識

ウィンドウのフレームは行数(ROWS)でも値の範囲(RANGE)でも指定できます。ORDER BY が日付なら、RANGE の幅は INTERVAL で書けます。

SELECT AVG(num_col) OVER (
         ORDER BY date_col
         RANGE BETWEEN INTERVAL '6 days' PRECEDING
                   AND CURRENT ROW)   -- 当日を含む7日間
FROM table_name;
行が欠けている日がある表では意味が変わる:ROWS 6 PRECEDING は「直前の6行」なので、データの無い日があると7日より長い期間を平均します。RANGE は日付そのものを見るため、行が無くても期間は7日のままです。
問題

daily_sales テーブルについて、各日の売上と当日を含む7日間の移動平均を求めてください。移動平均は実際の日付で7日間とし、行の無い日はその期間に売上が無かったものとして扱います(平均の分母には数えません)。値は小数第1位までとします。取得列は sold_on, amount, avg_7d、sold_on 昇順で返してください。

使用テーブル
▸ daily_sales
sold_onamount
2026-09-01100
2026-09-02200
2026-09-03300
2026-09-06600
2026-09-07700
2026-09-08800
期待出力
sold_onamountavg_7d
2026-09-01100100.0
2026-09-02200150.0
2026-09-03300200.0
2026-09-06600300.0
2026-09-07700380.0
2026-09-08800520.0
模範解答コード
SELECT
  sold_on,
  amount,
  ROUND(AVG(amount) OVER (
          ORDER BY sold_on
          RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW), 1) AS avg_7d  -- 日付で7日間を切る
FROM   daily_sales
ORDER BY sold_on;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM daily_sales             → 6行読み込み
  2. OVER (ORDER BY sold_on)      → 日付順に並べる
  3. RANGE INTERVAL '6 days'      → 各行で7日間の窓を決める
  4. AVG + ROUND                  → 窓内の平均を小数第1位へ
  5. ORDER BY sold_on             → 日付の昇順
  */
解説(テーブル変化・ポイント)
SELECT sold_on, amount, ROUND(AVG(amount) OVER ( ORDER BY sold_on RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW), 1) AS avg_7d FROM daily_sales ORDER BY sold_on;
LEGEND
データ取得・読込対象
① FROM daily_sales
FROM daily_salesdaily_sales 全6行を読み込みます。9月4日と9月5日は行が存在せず、日付が飛んでいます。
1 / 4
sold_onamount
2026-09-01100
2026-09-02200
2026-09-03300
2026-09-06600
2026-09-07700
2026-09-08800
全 6行 読込
学習ポイント
ROWS は行数、RANGE は値の幅:日付が連続していれば両者は一致しますが、欠測日があると別物になります。「直近7日」と言われたら、意味しているのは日付の幅の方です。
INTERVAL フレームは ORDER BY の型に従う:RANGE ... INTERVAL が使えるのは、ORDER BY が日付・時刻型で、オフセットが加減算できる場合です。数値列なら RANGE BETWEEN 100 PRECEDING のように同じ型で書きます。
期間の先頭は窓が短い:最初の数行は7日ぶんの履歴が無いため、少ない件数で平均されます。グラフの立ち上がりが不自然に見えるときは、7日揃うまで NULL にするか、開始日を後ろへずらして表示します。
アンチパターン
欠測日を無視して ROWS で数える:ROWS BETWEEN 6 PRECEDING は、休業日で行が飛ぶ表では実質10日以上を平均することがあります。日付の意味で7日を切りたいなら RANGE を使います。
0 として埋めるか、除くかを曖昧にする:売上の無い日を0行のままにすると分母から外れ、0で埋めると分母に入って平均が下がります。どちらが正しいかは指標の定義次第で、混在させると比較できません。
実務コラム:移動平均は「曜日の波」を消すための道具
日次のKPIは平日と週末で大きく形が変わるため、生の折れ線では傾向が読めません。7日移動平均は、ちょうど1週間ぶんを均すことで曜日の周期を打ち消し、増減の傾向だけを残します。だからこそ幅は7日でなければならず、5日や10日では曜日の波が残ります。月次の傾向を見るなら28日(4週間)を使い、30日にはしません。平均の幅は「見たい周期の整数倍」で選ぶ、と覚えておくと迷いません。
QUESTION 5

LATERAL で最終既知値 — 変更履歴から各日の適用価格を復元する

LATERALgenerate_series最終既知値履歴テーブル
前提知識

LATERAL を付けたサブクエリは、左側の各行の値を参照できます。日付の土台と組み合わせると「その日時点で最後に確定していた値」を1行ずつ引けます。

SELECT d.day, x.val
FROM   generate_series(...) AS d(day)
LEFT JOIN LATERAL (
  SELECT h.val FROM history_table h
  WHERE  h.changed_on <= d.day     -- 左の行の値を参照できる
  ORDER BY h.changed_on DESC
  LIMIT  1) x ON TRUE;
ON TRUE と LIMIT 1 が対:結合条件はサブクエリの WHERE 側で表現済みなので、ON には TRUE を置きます。LIMIT 1 により、左の1行に対して右は最大1行に決まります。
問題

price_changes テーブルは価格の変更があった日だけを記録した履歴です。ここから 2026-07-01 〜 2026-07-05 の5日分について、各日に適用されていた価格(その日以前で最も新しい変更後の価格)を求めてください。取得列は day, price、day 昇順で返してください。

使用テーブル
▸ price_changes
changed_onprice
2026-06-201000
2026-07-021200
2026-07-04900
期待出力
dayprice
2026-07-011000
2026-07-021200
2026-07-031200
2026-07-04900
2026-07-05900
模範解答コード
SELECT
  d.day::date AS day,
  p.price
FROM generate_series(
       DATE '2026-07-01',
       DATE '2026-07-05',
       INTERVAL '1 day') AS d(day)          -- 日付の土台(5行)
LEFT JOIN LATERAL (
  SELECT c.price
  FROM   price_changes c
  WHERE  c.changed_on <= d.day::date            -- その日までの変更だけ
  ORDER BY c.changed_on DESC
  LIMIT  1) p ON TRUE                        -- 最も新しい1件を採用
ORDER BY day;

/*
  実行順序(SQLの論理的な評価順):
  1. generate_series(...)         → 5日分の日付を生成
  2. LATERAL サブクエリを行ごとに評価 → その日以前の最新変更を1件取得
  3. LEFT JOIN ... ON TRUE        → 日付へ価格を連結
  4. SELECT day, price            → 2列を選択
  5. ORDER BY day                 → 日付の昇順
  */
解説(テーブル変化・ポイント)
SELECT d.day::date AS day, p.price FROM generate_series( DATE '2026-07-01', DATE '2026-07-05', INTERVAL '1 day') AS d(day) LEFT JOIN LATERAL ( SELECT c.price FROM price_changes c WHERE c.changed_on <= d.day::date ORDER BY c.changed_on DESC LIMIT 1) p ON TRUE ORDER BY day;
LEGEND
データ取得・読込対象
① generate_series で日付を生成
generate_series(DATE '2026-07-01', DATE '2026-07-05', INTERVAL '1 day')7月1日から7月5日までの5行を作ります。価格変更のあった日だけでは3行しか無いので、この土台が結果の行数を決めます。
1 / 6
d.day
2026-07-01
2026-07-02
2026-07-03
2026-07-04
2026-07-05
生成 5行
学習ポイント
履歴テーブルは「変わった時だけ」記録する:変更日だけを持つ設計は無駄が少ない代わりに、「ある日の値」を知るには最終既知値をたどる必要があります。LATERAL + ORDER BY … DESC LIMIT 1 がその標準形です。
LEFT JOIN にする理由:土台の先頭より前に変更が1件も無い期間では、サブクエリが0行を返します。LEFT JOIN なら価格が NULL の行として残り、JOIN だとその日が結果から消えます。欠落を見えるようにしておく方が安全です。
ウィンドウ関数でも書ける:履歴と日付を結合してから LAST_VALUEMAX(...) OVER で埋める書き方もあります。LATERAL 版は「1行につき1件だけ読む」意図が明確で、履歴が大きいときに (changed_on) のインデックスが効きやすい形です。
アンチパターン
履歴テーブルを直接集計する:変更のあった日だけを集計すると、7月3日・7月5日が結果から消えます。行の無い日は「値が無い日」ではなく「変わらなかった日」です。
ORDER BY を省いて LIMIT 1 を使う:並び順を指定しない LIMIT 1 はどの行が返るか決まりません。最終既知値を取るなら ORDER BY changed_on DESC と、同日複数変更に備えたタイブレーク列まで書き切ります。
実務コラム:有効期間を持たせるという選択肢
変更日だけを持つ履歴の代わりに、valid_from / valid_to の2列で有効期間を持たせる設計(有効期間モデル)もあります。各日の値は day >= valid_from AND day < valid_to の単純な結合で引けるようになり、この設問のような最終既知値の探索が不要になります。代償として、変更のたびに直前行の valid_to を更新する書き込みが増え、期間の重なりや隙間を防ぐ制約が必要です。読みやすさを取るか書き込みの単純さを取るかは、更新頻度と参照頻度で決めます。