月・日・週を個別の整数型フィールドで記録するという誤ち

あけましておめでとうございます。

年の初日ということで、私が過去に犯した日付関係の誤ち（と、行き着いた正しい方法）を紹介します。

# 「定期実行」機能

「毎月１日に◯◯する」
「毎月月末に◯◯する」
「毎月水曜日に◯◯する」

こういった、定期実行機能を実装する機会は、多くはないが、まあプログラマー人生で１回ぐらいは遭遇すると思います。

カレンダーや予定に直接関係ないアプリでも、会社業務にかかわる（請求やワークフロー）ものでは、どこかの時点で「定型業務は自動化してほしい」という要望が出てくるものです。

# 誤った実装が生まれるまでの流れ

さて「定期実行」機能を実装することになりました。定期実行するタスクは `recurring_tasks` テーブルに保存します。

そして、初期の要件では**タスクを実行する月・日は決まっていた**とします。

私「なら、実行する月・日をそれぞれNUMBER型の`month`と`day`にすればいいな。1~12か1~31までしか格納しないからメモリが無駄になるけど、富豪的解決だ。ヨシ！」

```SQL
-- 定期実行するタスク
CREATE TABLE recurring_tasks (
  month NUMBER NULL, -- 実行する月。毎月実行するタスクでは NULLにする
  day NUMBER NULL    -- 実行する日。毎日実行するタスクではNULLにする
);
```

「定期実行」機能はそつなく提供できたので、「**毎週◯曜日に実行したい**」という追加要望が出てきました。

私「なら `weekday`というフィールドを追加する。ヨシ！」

```SQL
-- 定期実行するタスク
CREATE TABLE recurring_tasks (
  month NUMBER NULL,  -- 実行する月。毎月実行するタスクでは NULLにする
  day NUMBER NULL,    -- 実行する日。毎日実行するタスクではNULLにする。weekdayを指定する場合もNULL。
  weekday NUMBER NULL -- 実行する曜日。dayで実行日を指定する場合はNULL。
);
```

次は当然「**毎月第１月曜日にだけ実行したい**」「**毎月最終金曜日にだけ実行したい**」といった、何週目かの指定の要望が出てきます。

私「で、でも `nth_week`というフィールドを追加して、第何週かを表現すれば、ヨシ！」

```SQL
-- 定期実行するタスク
CREATE TABLE recurring_tasks (
  month NUMBER NULL,   -- 実行する月。毎月実行するタスクでは NULLにする
  day NUMBER NULL,     -- 実行する日。毎日実行するタスクではNULLにする。weekdayを指定する場合もNULL。
  weekday NUMBER NULL, -- 実行する曜日。dayで実行日を指定する場合はNULL。
  nth_week NUMBER NULL -- 月の第何週目か。weekdayを指定しない場合はNULL。最終曜日の場合は-1
);
```

ここで、CTOからのご意見「DBのテーブルにはチェック制約を入れて意図しない値を保存しないようにするべきだよね。」

ユーザーからのご意見「第５月曜日がある月には第３月曜日に、第５月曜日がない月には第２月曜日に実行したいんですけど、そんな機能追加できますか？」

私「出来ないわけではないですけど・・・辛い」

# 今ならどう実装する？

「月」「日」「曜日」「何週目」を、それぞれ別々のフィールドにしたのが誤りでした。

考えてみれば、1年=365日なので「年に1回、特定の月日に実行する」パターンは365通りしかありません（2/29を加えれば366通り）。

「毎月1回、特定の日に実行する」パターンは31通りしかありません。

その他、「毎週◯曜日に実行」「毎月第▢◯曜日に実行」「毎月最終◯曜日に実行」「毎月最終日に実行」など、曜日や週を組み合わせたパターンも、多いとはいえ数十～数百通り程度です。

ならば、パターンに通し番号を振り、1つのフィールドで管理した方がスッキリ書けます。

```sql
CREATE TABLE execution_patterns (
  id NUMBER NOT NULL,
  label TEXT
);

CREATE TABLE recurring_tasks (
  execution_pattern_id NUMBER NOT NULL, -- 実行パターン
  FOREIGN KEY (execution_pattern_id) REFERENCES execution_patterns(id);
);

INSERT INTO execution_patterns (id, label) VALUES (1, '毎年1月1日に実行');
INSERT INTO execution_patterns (id, label) VALUES (2, '毎年1月2日に実行');
...
INSERT INTO execution_patterns (id, label) VALUES (31, '毎年1月31日に実行');
INSERT INTO execution_patterns (id, label) VALUES (32, '毎年2月1日に実行');
...
INSERT INTO execution_patterns (id, label) VALUES (366, '毎年12月31日に実行');

INSERT INTO execution_patterns (id, label) VALUES (367, '毎月1日に実行');
INSERT INTO execution_patterns (id, label) VALUES (368, '毎月2日に実行');
INSERT INTO execution_patterns (id, label) VALUES (369, '毎月3日に実行');
...
INSERT INTO execution_patterns (id, label) VALUES (397, '毎月31日に実行');

INSERT INTO execution_patterns (id, label) VALUES (398, '毎週日曜日に実行');
INSERT INTO execution_patterns (id, label) VALUES (399, '毎週月曜日に実行');
...
INSERT INTO execution_patterns (id, label) VALUES (404, '毎週土曜日に実行');

INSERT INTO execution_patterns (id, label) VALUES (405, '毎月第１日曜日に実行');
INSERT INTO execution_patterns (id, label) VALUES (406, '毎月第１月曜日に実行');
...
-- （以下、実行パターンごとにIDとラベルを追加）
```

もちろん、実行パターンごとに通し番号を振る方法では**アプリケーション側で、IDと実行パターンの対応関係を処理しなければなりません。**

しかし、`month`, `day`, `weekday`, `nth_week` の4つのフィールドがある場合も、アプリケーション側をシンプルに保てるわけではなりません。実行時や入力のバリデーションの際に、4フィールドの値に応じて、場合分けをせねばならないからです。

通し番号方式の方が、DBとアプリを併せた全体としては、シンプルな実装になると思います。

みなさんの参考になれば幸いです。