SQL で休日テーブルから n 営業日後の日付を取得する


指定された日付から n 営業日後の日付を計算したいという場合があります。謎の制約により 1 つの SELECT 文を投げて目的の日付を得たいとします。

まず以下のようにカレンダーテーブルから営業日が取得できる場合を考えます。

calendar
calendar_date is_holiday
2014-04-01 0
2014-04-02 0
2014-04-03 0
2014-04-04 0
2014-04-05 1
2014-04-06 1
2014-04-07 0
2014-04-08 0
2014-04-09 0
2014-04-10 0

この場合は単純に対象となる日付より大きい営業日日付を n レコードとってきて,最大のものが答えになります。

例えば SQLite だと以下のように書けます (以下 SQL はすべて SQLite の文法を採用)。

-- 2014-04-02 から 3 営業日後 (= 2014-04-07 を取得)
SELECT
   max(calendar_date)
FROM (
   SELECT
      calendar_date
   FROM
      calendar
   WHERE
      is_holiday = 0
      AND calendar_date > '2014-04-02'
   LIMIT
      3
)

では以下のようにカレンダーに休日情報しか含まれていない場合はどうでしょうか。

holiday
calendar_date
2014-04-05
2014-04-06
2014-04-12
2014-04-13
2014-04-19
2014-04-20
2014-04-26
2014-04-27
2014-04-29
2014-05-03

方針

基本的な方針は,営業日がなければ営業日を計算すればよいということで,休日間にある営業日数を数えるというものです。 n 営業日後を知りたいので,営業日数を累計していき,必要な日数に達したら微調整するという方向で考えます。

例えば 10 営業日後を知りたい場合,基準となる日付から次の休日までの営業日が 3 日,その次の休日まで 5 日,その次の休日まで 4 日それぞれ営業日があるとします。 2 つめの休日までに 8 営業日, 3 つめの休日までに 12 営業日あるので, 2 つめの休日と 3 つめの休日の間に 10 営業日後の日付が存在することになります。

休日間の営業日数を計算する

まず次の休日を取得します。テーブルには休日のレコードのみ存在するので,自身より大きい日付で最小のものが次の休日になります。

SELECT
   a.calendar_date AS next_holiday,
   b.calendar_date AS base_date
FROM
   holiday a
LEFT JOIN holiday b ON
   a.calendar_date = (SELECT min(calendar_date) FROM holiday WHERE calendar_date > b.calendar_date)
基準日の次の休日
next_holiday base_date
2014-04-05 (NULL)
2014-04-06 2014-04-05
2014-04-12 2014-04-06
2014-04-13 2014-04-12
2014-04-19 2014-04-13
2014-04-20 2014-04-19
2014-04-26 2014-04-20
2014-04-27 2014-04-26
2014-04-29 2014-04-27
2014-05-03 2014-04-29

今知りたいのは休日間に含まれる営業日日数なので,左の列から右の列を引けばよいことになります。最初の行は,具合が良いことに NULL になっているので,ここに基準日が来るようにすれば,基準日から最初の休日までの間の営業日数も取得できます。

SELECT
   a.calendar_date AS next_holiday,
   ifnull(b.calendar, '2014-04-09') AS base_date
   (julianday(a.calendar_date) - julianday(ifnull(b.calendar_date, '2014-04-09'))) - 1 AS business_day_to_next_holiday
FROM
   (SELECT calendar_date FROM holiday WHERE calendar_date > '2014-04-09') a
LEFT JOIN (SELECT calendar_date FROM holiday WHERE calendar_date > '2014-04-09') b ON
   a.calendar_date = (SELECT min(calendar_date) FROM holiday WHERE calendar_date > b.calendar_date)

植木算なので次の休日から基準日を引いたらさらに 1 を引くことを忘れずに。次の結果を得ます。

business_day_between_holidays
next_holiday base_date business_day_to_next_holiday
2014-04-12 2014-04-09 2
2014-04-13 2014-04-12 0
2014-04-19 2014-04-13 5
2014-04-20 2014-04-19 0
2014-04-26 2014-04-20 5
2014-04-27 2014-04-26 0
2014-04-29 2014-04-27 1
2014-05-03 2014-04-29 3

基準日の 2014-04-09 から次の休日までの営業日数を含め,正しく休日間の営業日数を取得することができました。

累計営業日数を計算する

次に欲しいのは休日までの基準日からの営業日の累計です。ある休日までの営業日の累計は,それ以前の休日における business_day_to_next_holiday の合計です。したがってこの条件で結合して合計をとればよいことになります。

SELECT
   x.calendar_date,
   sum(y.business_day_to_next_holiday) AS business_day_from_base_date
FROM
   holiday x
INNER JOIN [business_day_between_holidays] y ON
   x.calendar_date >= y.next_holiday
GROUP BY
   x.calendar_date

[business_day_between_holidays] は前述の次の休日までの営業日数を取得したテーブルです。これにより累計日数を取得できます。

business_day_cumulation
next_holiday business_day_from_base_date
2014-04-12 2
2014-04-13 2
2014-04-19 7
2014-04-20 7
2014-04-26 12
2014-04-27 12
2014-04-29 13
2014-05-03 16

n 営業日後を計算する

最後に n 営業日後を取得するために, n 以上となる最初の休日を取得します。累計営業日がちょうど n であれば,その休日の前日が n 営業日後になります。同様に累計営業日が n + d であれば,その休日から d + 1 日引くことで n 営業日後を取得できます。なお,休日から d + 1 日引いた結果が営業日であることは,テーブルの作り方から保証されます。以下で簡単に説明します。

選択した休日は累計営業日は n 以上となる最初の休日なので,その累計営業日を n + d (d ≧ 0) とします。その直前の休日までの累計営業日は n より小さいので n - k (k ≧ 1) とします。これらの休日間には (n + d) - (n - k) = d + k 日の営業日が存在することになります。いま k ≧ 1 なので,選択した休日と前の休日との間には d + 1 日以上の営業日が存在することになります。したがって選択した休日から d + 1 日引いても,その日は営業日です。

閑話休題,前述の計算により n 営業日後の日付を取得する SQL は以下のようになります。例えば n が 4 なら以下のようになります。

SELECT
   min(date(julianday(calendar_date) + 4 - (business_day_from_base_date + 1)))
FROM
   [business_day_cumulation]
WHERE
   business_day_from_base_date >= 4
結果
2014-04-15

[business_day_cumulation] は前述の累計営業日数テーブルです。

2014-04-09 の翌営業日は 2014-04-10, 2 営業日後は 2014-04-11, 3 営業日後は 2014-04-12, 13 が休業日なので 2014-04-14,そして 4 営業日後は 2014-04-15 なので,確かに所望の結果を得ています。

完成品

全体の SQL は以下のようになります。 [target_date][n] 営業日後の日付を取得します。

SELECT
   min(date(julianday(calendar_date) + [n] - (business_day_from_base_date + 1)))
FROM (
   SELECT
      x.calendar_date,
      sum(y.business_day_to_next_holiday) AS business_day_from_base_date
   FROM
      holiday x
   INNER JOIN (
      SELECT
         a.calendar_date AS next_holiday,
         b.calendar_date AS base_date,
         (julianday(a.calendar_date) - julianday(ifnull(b.calendar_date, [target_date]))) - 1 AS business_day_to_next_holiday
      FROM
         (SELECT calendar_date FROM holiday WHERE calendar_date > [target_date]) a
      LEFT JOIN (SELECT * FROM holiday WHERE calendar_date > [target_date]) b ON
         a.calendar_date = (SELECT min(calendar_date) FROM holiday WHERE calendar_date > b.calendar_date)
   ) y ON
      x.calendar_date >= y.next_holiday
   GROUP BY
      x.calendar_date
)
WHERE
   business_day_from_base_date >= [n]

問題と対策

累計営業日を計算する処理のパフォーマンスは良くありません。環境にもよりますが,数年分のデータ量でも結果が返ってくるまでに時間がかかります。結果を素早く返してもらうためには,あらかじめ休日テーブルの上限範囲を絞っておくなどの工夫が必要になります[A]。あるいは SQL 一発でなんとかしようとしたりせずに,呼び出し側で動的に SQL を組み立てたり,そもそも休日テーブルから何営業日後を取得するみたいな処理を行うなという話ではありますが。

脚注

  1. 現実的な問題では n がそう大きくなることはないので (例えば「100 営業日」みたいな言葉は聞いたことがない),例えば半年程度で上限を設定するというのはそこまで無茶な制約ではないかと。 []