"厳密に" 日付時刻のフォーマットをチェックする

以下も併せてお読みください。

- [日付時刻関連のクラスを活用しよう](http://qiita.com/mpyw/items/e42fa173e5a26220a785)
- [日付時刻関連で使われる書式の調査](http://qiita.com/mpyw/items/55df438d12ef8e94dbc5)

# 導入

上に述べた記事でさまざまな入力パターンについて検証していますが、 **「どこまでが有効と見なされてパースされるか、どこからが無効と見なされて例外がスローされるか」**が非常に曖昧でありました。

```php:A.&nbsp;これは通る
echo (new \DateTime('2016/02/29'))->format('Y-m-d'); // 2016-02-29
```

```php:B.&nbsp;これも通る
echo (new \DateTime('2015/02/29'))->format('Y-m-d'); // 2015-03-01
```

```php:C.&nbsp;これは通らない
echo (new \DateTime('2015/01/32'))->format('Y-m-d'); // Fatal error: Uncaught exception 'Exception' with message 'DateTime::__construct(): Failed to parse time string (2015/02/32) at position 9 (2): Unexpected character'
```

ここでは`A`だけを通過させる方法を考えてみたいと思います。

# メソッドとその使用例

[DateTime::getLastErrors]または[DateTimeImmutable::getLastErrors]を用いると、上記では通過していたものについてのエラーも調べることが出来ます。結果は以下の形式の連想配列として得られます。

<table>
<tr><th>キー</th><th>値</th>
<tr><td><code>warning_count</code></td><td><code>warning</code>の同時発生件数</td></tr>
<tr><td><code>warnings</code></td><td><code>warning</code>に関する連想配列<table><tr><th>キー</th><th>値</th><tr><td>オフセット<strong><br />(但し未定義のオフセットを返すことがあるのでアテに出来ない)</strong></td><td>エラーメッセージ</td></tr></table></td></tr>
<tr><td><code>error_count</code></td><td><code>error</code>の同時発生件数</td></tr>
<tr><td><code>errors</code></td><td><code>error</code>に関する連想配列<table><tr><th>キー</th><th>値</th><tr><td>オフセット</td><td>エラーメッセージ</td></tr></table></td></tr>
</table>

```php:Aについての返り値
new \DateTime('2016/02/29');
var_dump(\DateTimeImmutable::getLastErrors());
/*
array(4) {
  ["warning_count"]=>
  int(0)
  ["warnings"]=>
  array(0) {
  }
  ["error_count"]=>
  int(0)
  ["errors"]=>
  array(0) {
  }
}
*/
```

```php:Bについての返り値
new \DateTime('2015/02/29');
var_dump(\DateTimeImmutable::getLastErrors());
/*
array(4) {
  ["warning_count"]=>
  int(1)
  ["warnings"]=>
  array(1) {
    [11]=>
    string(27) "The parsed date was invalid"
  }
  ["error_count"]=>
  int(0)
  ["errors"]=>
  array(0) {
  }
}
*/
```

```php:Cについての返り値
try {
    new \DateTime('2015/01/32');
} catch (\Exception $e) {
    var_dump(\DateTimeImmutable::getLastErrors());
}
/*
array(4) {
  ["warning_count"]=>
  int(0)
  ["warnings"]=>
  array(0) {
  }
  ["error_count"]=>
  int(1)
  ["errors"]=>
  array(1) {
    [9]=>
    string(20) "Unexpected character"
  }
}
*/
```

# 注意点

## エラーレベルには2種類がある

| エラーレベル | 発生時の動作 | 対応する導入の例 |
|:------:|:------|:---:|
|`error`|`Exception`をスローし、且つエラー取得メソッドで報告する | `C` |
|`warning`|`Exception`をスロー**せず**、エラー取得メソッドで報告する | `B` |

## 対象メソッドを1回コールするごとにリセットされる

```php:直近のコールでエラーが発生しなかった場合もクリアされる
new \DateTime('2015/02/29');
new \DateTime('2016/02/29');
var_dump(\DateTime::getLastErrors());
/*
array(4) {
  ["warning_count"]=>
  int(0)
  ["warnings"]=>
  array(0) {
  }
  ["error_count"]=>
  int(0)
  ["errors"]=>
  array(0) {
  }
}
*/
```

## [DateTime::getLastErrors]と<br />[DateTimeImmutable::getLastErrors]は同一視される

**2つのメソッドはクラスごとに区別されません**。

```php:区別せずに取得してしまう例
new \DateTimeImmutable('2015/02/29');
var_dump(\DateTime::getLastErrors());
/*
array(4) {
  ["warning_count"]=>
  int(1)
  ["warnings"]=>
  array(1) {
    [11]=>
    string(27) "The parsed date was invalid"
  }
  ["error_count"]=>
  int(0)
  ["errors"]=>
  array(0) {
  }
}
*/
```

## 全てのメソッドが対象になるわけではない

**文字列からパースする**以下のメソッドに限られます。

- [DateTime::__construct]
- [DateTime::modify]
- [DateTime::createFromFormat]
- [DateTimeImmutable::__construct]
- [DateTimeImmutable::modify]
- [DateTimeImmutable::createFromFormat]

```php:スルーされる例
$date = (new \DateTime)->setDate(2015, 1, 32);
echo $date->format('Y-m-d') . PHP_EOL;
var_dump(\DateTime::getLastErrors());
/*
2015-02-01
array(4) {
  ["warning_count"]=>
  int(0)
  ["warnings"]=>
  array(0) {
  }
  ["error_count"]=>
  int(0)
  ["errors"]=>
  array(0) {
  }
}
*/
```

# エラー取得メソッドの利用例

## バリデーションに利用

**正規表現を書く必要が無くなる**のは大きなメリットかもしれません。

```php:実装例
function check_datetime_format($format, $time) {
    \DateTime::createFromFormat($format, $time);
    $info = \DateTime::getLastErrors();
    return !$info['errors'] && !$info['warnings'];
}
```

```php:テスト&nbsp;(何も表示されなければ成功)
assert('check_datetime_format("Y/m/d", "2015/01/01") === true');
assert('check_datetime_format("Y/m/d", "15/1/1") === true'); # 0015/01/01
assert('check_datetime_format("y/m/d", "15/1/1") === true'); # 2015/01/01
assert('check_datetime_format("Y/m/d", "2015/001/001") === false');
assert('check_datetime_format("Y/m/d", "2015/01/31") === true');
assert('check_datetime_format("Y/m/d", "2015/01/32") === false');
assert('check_datetime_format("Y/m/d", "2015/02/29") === false');
assert('check_datetime_format("Y/m/d", "2016/02/29") === true');
assert('check_datetime_format("H:i:s", "02:03:04") === true');
assert('check_datetime_format("H:i:s", "2:03:04") === true');
assert('check_datetime_format("H:i:s", "02:3:04") === false');
assert('check_datetime_format("H:i:s", "02:03:4") === false');
```

但し、細かい挙動のカスタマイズは出来ず、あくまでPHPの判断基準に従うことになります。オリジナルの仕様が明確に定められている場合は正規表現を使うべきでしょう。

## 継承したクラスでオーバーライドして利用

ここでは、全ての対象メソッドで厳密なチェックを行うようにオーバーライドしてみます。

- 複数のエラーが発生していた場合は、先頭の1つだけを`Exception`のメッセージとします。
- メッセージ内容に一貫性を持たせるように、標準の仕様に従ったメッセージ形式にしています。
- [DateTime::createFromFormat]あるいは[DateTimeImmutable::createFromFormat]でも`Exception`をスローするようにしています。これは標準の仕様とは異なります。

2クラスともに同じコードを書くのは冗長なのでトレイトの利用を考えましたが、コンストラクタだけはトレイト中に含められないようなので、今回はべた書きしました。

```php:実装例
class StrictDateTime extends \DateTime {
    protected function checkErrors($method, $time) {
        static $formats = [
            'error'   => '%s(): Failed to parse string (%s) at position %d (%s): %s',
            'warning' => '%s(): Failed to parse string (%s): %s',
        ];
        $info = \DateTime::getLastErrors();
        foreach ($info['errors'] as $offset => $error) {
            throw new \Exception(sprintf($formats['error'], $method, $time, $offset, $time[$offset], $error));
        }
        foreach ($info['warnings'] as $offset => $warning) {
            throw new \Exception(sprintf($formats['warning'], $method, $time, $warning));
        }
    }
    public function __construct($time = 'now', \DateTimeZone $timezone = null) {
        parent::__construct($time, $timezone);
        self::checkErrors(__METHOD__, $time);
    }
    public function modify($modify) {
        parent::modify($modify);
        self::checkErrors(__METHOD__, $modify);
        return $this;
    }
    public static function createFromFormat($format, $time, \DateTimeZone $timezone = null) {
        if ($timezone !== null) {
            $return = parent::createFromFormat($format, $time, $timezone);
        } else {
            $return = parent::createFromFormat($format, $time);
        }
        self::checkErrors(__METHOD__, $time);
        return $return;
    }
}
class StrictDateTimeImmutable extends \DateTimeImmutable {
    protected function checkErrors($method, $time) {
        static $formats = [
            'error'   => '%s(): Failed to parse string (%s) at position %d (%s): %s',
            'warning' => '%s(): Failed to parse string (%s): %s',
        ];
        $info = \DateTimeImmutable::getLastErrors();
        foreach ($info['errors'] as $offset => $error) {
            throw new \Exception(sprintf($formats['error'], $method, $time, $offset, $time[$offset], $error));
        }
        foreach ($info['warnings'] as $offset => $warning) {
            throw new \Exception(sprintf($formats['warning'], $method, $time, $warning));
        }
    }
    public function __construct($time = 'now', \DateTimeZone $timezone = null) {
        parent::__construct($time, $timezone);
        self::checkErrors(__METHOD__, $time);
    }
    public function modify($modify) {
        $return = parent::modify($modify);
        self::checkErrors(__METHOD__, $modify);
        return $return;
    }
    public static function createFromFormat($format, $time, \DateTimeZone $timezone = null) {
        if ($timezone !== null) {
            $return = parent::createFromFormat($format, $time, $timezone);
        } else {
            $return = parent::createFromFormat($format, $time);
        }
        self::checkErrors(__METHOD__, $time);
        return $return;
    }
}
```

```php:テスト
誰か書いてください
```

[DateTime::getLastErrors]: http://www.php.net/manual/ja/datetime.getlasterrors.php
[DateTimeImmutable::getLastErrors]: http://www.php.net/manual/ja/datetimeimmutable.getlasterrors.php
[DateTime::__construct]: http://www.php.net/manual/ja/datetime.construct.php
[DateTimeImmutable::__construct]: http://www.php.net/manual/ja/datetimeimmutable.construct.php
[DateTime::modify]: http://www.php.net/manual/ja/datetime.modify.php
[DateTimeImmutable::modify]: http://www.php.net/manual/ja/datetimeimmutable.modify.php
[DateTime::createFromFormat]: http://www.php.net/manual/ja/datetime.createfromformat.php
[DateTimeImmutable::createFromFormat]: http://www.php.net/manual/ja/datetimeimmutable.createfromformat.php