PHP

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

More than 3 years have passed since last update.

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


導入

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


A. これは通る

echo (new \DateTime('2016/02/29'))->format('Y-m-d'); // 2016-02-29



B. これも通る

echo (new \DateTime('2015/02/29'))->format('Y-m-d'); // 2015-03-01



C. これは通らない

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を用いると、上記では通過していたものについてのエラーも調べることが出来ます。結果は以下の形式の連想配列として得られます。

キー

warning_count

warningの同時発生件数

warnings

warningに関する連想配列

キー

オフセット
(但し未定義のオフセットを返すことがあるのでアテに出来ない)

エラーメッセージ

error_count

errorの同時発生件数

errors

errorに関する連想配列

キー

オフセット
エラーメッセージ


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) {
}
}
*/



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) {
}
}
*/



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回コールするごとにリセットされる


直近のコールでエラーが発生しなかった場合もクリアされる

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
DateTimeImmutable::getLastErrorsは同一視される

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


区別せずに取得してしまう例

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) {
}
}
*/



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

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


スルーされる例

$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) {
}
}
*/



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


バリデーションに利用

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


実装例

function check_datetime_format($format, $time) {

\DateTime::createFromFormat($format, $time);
$info = \DateTime::getLastErrors();
return !$info['errors'] && !$info['warnings'];
}


テスト (何も表示されなければ成功)

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


実装例

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;
}
}


テスト

誰か書いてください