はらへり日記

腹に弾丸

PHPでISO8061に準拠した日付フォーマットをバリデーションする

この記事は

PHPアドベントカレンダー13日目の記事です。大遅刻ですごめんなさい。

qiita.com

正直、なぜか投稿した気になってしまってました…ちゃんとやらなきゃダメですよね気をつけます…。

したいこと

すいません。タイトルちょっと厳密に言うと違います。もっというとPHP関係ないかもしれない。

厳密には「ISO8061に従い、かつ年月日秒とタイムゾーンまで指定されているかどうか」バリデーションする方法です。

例えば以下のような書式はISO8061に準拠、かつ上記条件を満たしています。

2016-09-30T12:00:00+09:00

ですが、以下の書式はISO8061に準拠しながらも分秒数は指定されていません。

2016-09-30

とある実装で、分秒数まで指定された状態のバリデーションをかけたい場面があったのでどう実現するか考えました。

※ 今後、「ISO8061に従い、かつ年月日秒とタイムゾーンまで指定されているかどうか」といちいち言うと長いので便宜上、「ISO8061完全形」と呼称します。

そもそもISO8061とは

ISOとは皆様御存知、国際レベルでの標準化団体です。

それによって定められたISO8061はざっくり言うなら日付の表記方法を定めたものです。

仕様書を探して初めて知ったんですが、ISOの仕様書は有料みたいですね。なので今回はWikipediaの情報を元に進めました。

ISO 8601 - Wikipedia

方法を考える

まず、行わなければいけないバリデーションは2つです。

  • フォーマットがISO8061完全形であるかどうか
  • 存在する日付かどうか(9/31とかじゃないか的な)

この2つを実現するために、ざっくり以下の方法が考えられます。

  • 既存ライブラリで頑張る
  • 自分で正規表現とか頑張る
  • (あるなら)PHP標準関数とかで頑張る

順番に考えます。

既存ライブラリで頑張る

PHPの日付系ライブラリで真っ先に思いつくのはCarbonです。

carbon.nesbot.com

柔軟なパーサーや豊富でシンプルなAPIやテスタビリティの高さから使ってる人は非常に多いのではないでしょうか。

Carbonを知らないよって方はこの紹介記事がおすすめです。

blog.asial.co.jp

ただ、Carbonにはバリデーションの仕組みはほぼなく、文字列がISO8061完全形かどうかのチェックはできなそうです。

(あったら教えてください)

例えばCarbonは文字列を渡すことでインスタンスを生成できますが、その解釈が柔軟すぎてそれをテキストフォーマットのバリデーションとして使用するのは無理そうです。

例えば2016 12/25という文字列を渡してみるときちんと2016-12-25 20:16:00といった感じでデータが作成されます。

<?php

require __DIR__.'/vendor/autoload.php';

use Carbon\Carbon;

$date = new Carbon('2016 12/25');
echo (string) $date;

では、存在しない日付をチェックする手段としてはどうでしょう?

例えば2016-09-31T12:00:00+09:00はISO8061完全形としては正しいですが、9/31という日付は存在しません。

そこで、ISO8061完全形としてのバリデーションが通った文字列が日付的に有効かどうか調べる方法としてCarbonインスタンスの生成がうまくいくかどうか試してみます。

ちなみにでたらめな文字列を投げると例外を投げてくれるのでおかしな日付もきっと投げてくれるはず。

<?php

require __DIR__.'/vendor/autoload.php';

use Carbon\Carbon;

// Exception
$invalidDate = new Carbon('でたらめ');

では試しにこんなコードを実行してみます。

<?php

require __DIR__.'/vendor/autoload.php';

use Carbon\Carbon;

$date = new Carbon('2016-09-31T12:00:00+09:00');
echo (string) $date;

するとこうなります。

gyazo.com

なぜなのか\(^o^)/

CarbonはDateTimeクラスを継承してる

DateTimeクラスとはPHPの標準クラスです。

PHP: DateTime - Manual

CarbonはこのDateTimeクラスを継承しているので、インスタンス生成に渡される文字列の解釈はDateTimeの仕様に依存しています。

そして、DateTimeではなぜか9/31が通るようになっています。

そしてもっというと9/32は例外を投げます。

<?php

$date1 = new \DateTime('2016-09-31'); // OK
$date2 = new \DateTime('2016-09-32'); // Throws Exception

中のコードまでは読んでないので推測ですが、おおかた正規表現(0[1-9]|[12][0-9]|3[01])みたいなチェックをしているだけな気がします。

ということでCarbonはISO8061完全形のバリデーション、及び存在する日付かどうかのバリデーションには使えなさそうです。

すごく念のためですがCarbon sageなわけではないです。むしろCarbon無いと生きていけない

自分で正規表現とか頑張る

一番避けたいと思いつつ、結論から言うと今回はISO8061完全形フォーマットになっているかどうかのバリデーションはこれで実装しました。

というのも、

  • ISO8061形に対してバリデーションしてるよさげなcomposerライブラリが見つけられなかった
  • PHP標準関数にもISO8061完全形かどうかチェックしてくれるものはなさそうだった

という理由からです。

そもそもISO8061は特定のフォーマットでなく、いくつかのフォーマットがあるのでそれに対してバリデーションというと今回求めてる形式以外も許容しなければなりません。

仕様書に目を通していない以上、適当なことを事実として断言はできませんが世の認識的にも様々な表現方法がある、といった認識が一般的な気がします。

ISO 8601 - Wikipedia

というわけで正規表現ドーン!

<?php

$matches = [];

$isValidFormat = preg_match(
    '/^(\d{1,4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([0-1][0-9]|2[0-4]):[0-5][0-9]:[0-5][0-9](Z|(\+|-)([01][0-9]|2[0-4]):?([0-5][0-9])?)$/',
    $date, $matches
) === 1;

$matchesは後ほど、存在する日付かどうかのチェックに使用するためにいくつか文字列を抜き出しています。

読むの嫌になりますよね。読まなくていいです。

こういうものはコードを舐める用に読んでも絶対にミスする可能性があるのでテストケースでカバーしましょう。

思いつく範囲のテストケースを書き出して、ユニットテストでこの正規表現の質を担保しています。

正常系は省略して、異常系のみ書いてます。(以降も同じく)

<?php

$testCases = [
    // 日付のみ指定のケース
    '2016-09-30',
    // 日付と時刻の間のTが抜けてるケース
    '2016-09-01 12:00:00+0900',
    // タイムゾーンが抜けてるケース
    '2016-09-30T12:00:00',
];

あとは日付が存在するかどうかのチェックをすればよさそうです。

PHP標準関数とかで頑張る

日付が実在するかどうかのチェックですが、PHPにはcheckdate()という標準関数があります。

PHP: checkdate - Manual

先ほど実装した正規表現と合わせてこの関数を使用すれば9/31といった存在しない日付も弾くことができます。

<?php

$matches = [];

$isValidFormat = preg_match(
    '/^(\d{1,4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([0-1][0-9]|2[0-4]):[0-5][0-9]:[0-5][0-9](Z|(\+|-)([01][0-9]|2[0-4]):?([0-5][0-9])?)$/',
    $date, $matches
) === 1;

$isValidDate = true;

try {
    $isValidDate = checkdate($matches[2], $matches[3], $matches[1]);
} catch (\Exception $e) {
    $isValidDate = false;
}

return $isValidFormat && $isValidDate;

先ほどの正規表現で抜き出した年、月、日をcheckdate()で判別することで存在しない日付のチェックは通らないようになっています。

もちろん、PHPの標準関数を使ってるからと油断せずにテストケースも追加しておきます。

<?php

$testCases = [
    // 日付がでたらめなケース
    '2016-15-01T12:00:00+0900', // 月
    '2016-09-31T12:00:00+0900', // 日
    '2016-09-01T25:00:00+0900', // 時
    '2016-09-01T12:61:00+0900', // 分
    '2016-09-01T12:00:61+0900', // 秒
    '2016-09-31T12:00:00+0900', // 存在しない31日
];

完成形

全部のコードをがっちゃんこするとこんな感じです。

<?php

/**
 * @param string  $date
 * @return bool
 */
function validateDate (string $date) 
{
    $matches = [];
    $isValidFormat = preg_match(
        '/^(\d{1,4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([0-1][0-9]|2[0-4]):[0-5][0-9]:[0-5][0-9](Z|(\+|-)([01][0-9]|2[0-4]):?([0-5][0-9])?)$/',
        $date, $matches
    ) === 1;
    $isValidDate = true;

    try {
        $isValidDate = checkdate($matches[2], $matches[3], $matches[1]);
    } catch (\Exception $e) {
        $isValidDate = false;
    }

    return $isValidFormat && $isValidDate;
}

テストケースはこんな感じ。

<?php

$testCases = [
    // 日付のみ指定のケース
    '2016-09-30',
    // 日付と時刻の間のTが抜けてるケース
    '2016-09-01 12:00:00+0900',
    // タイムゾーンが抜けてるケース
    '2016-09-30T12:00:00',
    // 日付がでたらめなケース
    '2016-15-01T12:00:00+0900', // 月
    '2016-09-31T12:00:00+0900', // 日
    '2016-09-01T25:00:00+0900', // 時
    '2016-09-01T12:61:00+0900', // 分
    '2016-09-01T12:00:61+0900', // 秒
    '2016-09-31T12:00:00+0900', // 存在しない31日
];

テストはPHPUnitなりでよしなに🙏

まとめ

PHPの話というよりは文字列をparseする話になってしまいましたが、いろいろと勉強になりました。

ISO8061を知ってるようで知らなかったことやPHP標準関数とかを知れたのでよかったです。

後はテストの大事さですね。こんな正規表現絶対読めない。

とにもかくにも投稿遅れてほんとスイマセン。来年はもっと勉強して「DateTimeクラスの実装直したぜ(ドヤァ」ぐらいの記事書きたいですね。

それでは皆様、メリークリスマス!