LoginSignup
43

More than 5 years have passed since last update.

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

Last updated at Posted at 2015-03-10

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

導入

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

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;
    }
}
テスト
誰か書いてください

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
43