src/Chippyash/Validation/Common/ISO8601DateString.php
<?php
declare(strict_types=1);
/**
* Chippyash/validation
*
* Functional validation
*
* Common validations
*
* @author Ashley Kitson
* @copyright Ashley Kitson, 2015, UK
*
* @link http://php.net/manual/en/functions.anonymous.php
*/
namespace Chippyash\Validation\Common;
use Chippyash\Validation\Common\ISO8601\Constants as C;
use Chippyash\Validation\Common\ISO8601\MatchDate;
use Chippyash\Validation\Common\ISO8601\SplitDate;
use Chippyash\Validation\Exceptions\InvalidParameterException;
use Monad\FTry;
use Monad\Match;
use Monad\Option;
/**
* Validator for ISO 8601 Date string
* This validator accepts ALL valid ISO8601 date strings.
* NB, valid format ISO 8601 datestrings may not be compatible with the PHP
* DateTime constructor. You can add an additional check when constructing this
* class to check for compatibility with DateTime.
*
* Constants used by this class are separated out into ISO8601/Constants for convenience
*/
class ISO8601DateString extends AbstractValidator
{
/**
* The format we are using
* @var int
*/
protected $format = C::FORMAT_EXTENDED;
/**
* Shall I enforce presence of time part?
* @var boolean
*/
protected $enforceTime = false;
/**
* Shall I enforce presence or zone part?
* @var boolean
*/
protected $enforceZone = false;
/**
* If I am using signed years, how many digits do I expect
* @var int
*/
protected $numSignedDigits = 0;
/**
* Have we found a time part?
* @var boolean
*/
protected $timepartFound = false;
/**
* Have we found a zone part?
* @var boolean
*/
protected $zonepartFound = false;
/**
* Are we allowing a lax time sparator?
* @var boolean
*/
protected $laxTime = false;
/**
* Are we allowing a lax zone separator?
* @var boolean
*/
protected $laxZone = false;
/**
* Are we checking \DateTime compatibility?
* @var boolean
*/
protected $phpCheck = false;
/**
* regex patterns
* NB Remember that date value is lowercased, so patterns must match
* lowercased alphas
*
* @var array
*/
protected $formatRegex = [
C::FORMAT_BASIC => [
MatchDate::STRDATE => [
C::FMT_KEY_BYO => '/^\d{4}$/',
C::FMT_KEY_BYMD => '/^\d{4}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])$/',
C::FMT_KEY_BW => '/^\d{4}w(0[1-9]|[1-4]\d|5[0-3])$/',
C::FMT_KEY_BWPD => '/^\d{4}w(0[1-9]|[1-4]\d|5[0-3])[1-7]$/',
C::FMT_KEY_BO => '/^\d{4}(00[1-9]|[12]\d{2}|3[0-5]\d|36[0-6])$/'
],
MatchDate::STRTIME => [
C::FMT_KEY_BTHMS => '/^([01]\d|2[0-4])([0-5]\d)([0-5]\d)$/',
C::FMT_KEY_BTHM => '/^([01]\d|2[0-4])([0-5]\d)$/',
C::FMT_KEY_BTH => '/^([01]\d|2[0-4])$/',
C::FMT_KEY_BDTHMS => '/^([01]\d|2[0-4])([0-5]\d)([0-5]\d)[\.,]\d{1,}$/',
C::FMT_KEY_BDTHM => '/^([01]\d|2[0-4])([0-5]\d)[\.,]\d{1,}$/',
C::FMT_KEY_BDTH => '/^([01]\d|2[0-4])[\.,]\d{1,}$/',
],
MatchDate::STRZONE => [
C::FMT_KEY_BZPH => '/^\+([01]\d|2[0-4])$/',
C::FMT_KEY_BZNH => '/^\-(0[1-9]|1\d|2[0-4])$/',
C::FMT_KEY_BZPHM => '/^\+([01]\d|2[0-4])([0-4]\d|5[1-9])$/',
C::FMT_KEY_BZNHM => '/^\-([01]\d|2[0-4])(0[1-9]|[1-4]\d|5[1-9])$/',
C::FMT_KEY_BZUTC => '/^UTC$/' //workaround for Z UTC designation
]
],
C::FORMAT_BASIC_SIGNED => [
//NB ## is replaced with real number of digits required for signed year
//due to limitation of basic format, only an extended year is supported
MatchDate::STRDATE => [
C::FMT_KEY_SBYO => '/^[\+\-]\d{##}$/',
],
//time and zone are filled with basic regex patterns
MatchDate::STRTIME => null,
MatchDate::STRZONE => null
],
C::FORMAT_EXTENDED => [
MatchDate::STRDATE => [
C::FMT_KEY_EYO => '/^\d{4}$/',
C::FMT_KEY_EYM => '/^\d{4}\-(0[1-9]|1[0-2])$/',
C::FMT_KEY_EYMD => '/^\d{4}\-(0[1-9]|1[0-2])\-(0[1-9]|[12]\d|3[01])$/',
C::FMT_KEY_EW => '/^\d{4}\-w(0[1-9]|[1-4]\d|5[0-3])$/',
C::FMT_KEY_EWPD => '/^\d{4}\-w(0[1-9]|[1-4]\d|5[0-3])[1-7]$/',
C::FMT_KEY_EO => '/^\d{4}\-(00[1-9]|[12]\d{2}|3[0-5]\d|36[0-6])$/'
],
MatchDate::STRTIME => [
C::FMT_KEY_ETHMS => '/^([01]\d|2[0-4]):([0-5]\d):([0-5]\d)$/',
C::FMT_KEY_ETHM => '/^([01]\d|2[0-4]):([0-5]\d)$/',
C::FMT_KEY_EDTHMS => '/^([01]\d|2[0-4]):([0-5]\d):([0-5]\d)[\.,]\d{1,}$/',
C::FMT_KEY_EDTHM => '/^([01]\d|2[0-4]):([0-5]\d)[\.,]\d{1,}$/',
],
MatchDate::STRZONE => [
C::FMT_KEY_EZPH => '/^\+([01]\d|2[0-4])$/',
C::FMT_KEY_EZNH => '/^\-(0[1-9]|1\d|2[0-4])$/',
C::FMT_KEY_EZPHM => '/^\+([01]\d|2[0-4]):([0-4]\d|5[1-9])$/',
C::FMT_KEY_EZNHM => '/^\-([01]\d|2[0-4]):(0[1-9]|[1-4]\d|5[1-9])$/',
C::FMT_KEY_EZUTC => '/^UTC$/' //workaround for Z UTC designation
]
],
C::FORMAT_EXTENDED_SIGNED => [
//NB ## is replaced with real number of digits required for signed year
MatchDate::STRDATE => [
C::FMT_KEY_SEYO => '/^[\+\-]\d{##}$/',
C::FMT_KEY_SEYM => '/^[\+\-]\d{##}\-(0[1-9]|1[0-2])$/',
C::FMT_KEY_SEYMD => '/^[\+\-]\d{##}\-(0[1-9]|1[0-2])\-(0[1-9]|[12]\d|3[01])$/',
],
//time and zone are filled with extended regex patterns
MatchDate::STRTIME => null,
MatchDate::STRZONE => null
],
];
/**
* Constructor
*
* You can provide a format. Default is FORMAT_EXTENDED
* You can OR a format with the ENFORCE_.. bits e.g.
* FORMAT_EXTENDED | ENFORCE_TIME | ENFORCE_ZONE
*
* You can also set laxness for the time and zone separators
* a lax time means that instead of the T separator, a space can be used
* ditto for lax zone (i.e. space between time part and zone part)
* e.g. FORMAT_EXTENDED | ENFORCE_TIME | LAX_TIME
*
* To check for PHP DateTime compatibility OR the format with CHECK_PHP_PARSEABLE
* e.g. FORMAT_EXTENDED | CHECK_PHP_PARSEABLE
*
* @param int $format
* @param int $numSignedDigits required if Signed dates are to be validated
*
* @throws InvalidParameterException
* @throws \Chippyash\Validation\Exceptions\ValidationException
*/
public function __construct(?int $format = null, ?int $numSignedDigits = null)
{
Match::on($format)
->null(
function (): void {
$this->format = C::FORMAT_EXTENDED;
}
)
->any(
function () use ($format): void {
$this->setFormatAndFlags($format);
}
);
InvalidParameterException::assert(
function () {
return !$this->enforceTime && $this->enforceZone;
},
C::ERR_ENFORCEZONE_NOTIME
);
InvalidParameterException::assert(
function () {
return !$this->laxTime && $this->laxZone;
},
C::ERR_LAXZONE_NOTIME
);
Match::on(
Option::create(
$this->format == C::FORMAT_BASIC_SIGNED || $this->format == C::FORMAT_EXTENDED_SIGNED,
false
)
)
->Monad_Option_Some(
function () use ($numSignedDigits): void {
InvalidParameterException::assert(
function () use ($numSignedDigits) {
return is_null($numSignedDigits);
},
C::ERR_NOSIGNEDDIGITS
);
InvalidParameterException::assert(
function () use ($numSignedDigits) {
return $numSignedDigits < 4;
},
C::ERR_MINSIGNEDDIGITS
);
$this->numSignedDigits = $numSignedDigits;
$this->prepareSignedRegexes();
}
);
}
/**
* Set datestring formats and other flags based on format
*
* @param int $format
*/
protected function setFormatAndFlags(int $format): void
{
$fullMask = C::MASK_FORMAT | C::MASK_ENFORCE | C::MASK_LAX | C::MASK_PHP;
$fmt = $format & $fullMask;
$enforcement = $fmt & C::MASK_ENFORCE;
$laxness = $fmt & C::MASK_LAX;
$phpCompatibility = $fmt & C::MASK_PHP;
$this->format = ($fmt - ($enforcement + $laxness + $phpCompatibility)) & C::MASK_FORMAT;
$this->format = ($this->format == C::FORMAT_NONE ? C::FORMAT_EXTENDED : $this->format);
$this->enforceTime = (C::ENFORCE_TIME & $enforcement) == C::ENFORCE_TIME;
$this->enforceZone = (C::ENFORCE_ZONE & $enforcement) == C::ENFORCE_ZONE;
$this->laxTime = (C::LAX_TIME & $laxness) == C::LAX_TIME;
$this->laxZone = (C::LAX_ZONE & $laxness) == C::LAX_ZONE;
$this->phpCheck = (C::CHECK_PHP_PARSEABLE & $fmt) == C::CHECK_PHP_PARSEABLE;
}
/**
* Prepare signed regex patterns with required number of digits
*/
protected function prepareSignedRegexes(): void
{
Match::on(Option::create($this->format == C::FORMAT_BASIC_SIGNED, false))
->Monad_Option_Some(
function (): void {
foreach ($this->formatRegex[C::FORMAT_BASIC_SIGNED][MatchDate::STRDATE] as &$regex) {
$regex = str_replace('##', $this->numSignedDigits, $regex);
}
//time and zone regex patterns are same as basic
$this->formatRegex[C::FORMAT_BASIC_SIGNED][MatchDate::STRTIME] =
$this->formatRegex[C::FORMAT_BASIC][MatchDate::STRTIME];
$this->formatRegex[C::FORMAT_BASIC_SIGNED][MatchDate::STRZONE] =
$this->formatRegex[C::FORMAT_BASIC][MatchDate::STRZONE];
}
);
Match::on(Option::create($this->format == C::FORMAT_EXTENDED_SIGNED, false))
->Monad_Option_Some(
function (): void {
foreach ($this->formatRegex[C::FORMAT_EXTENDED_SIGNED][MatchDate::STRDATE] as &$regex) {
$regex = str_replace('##', $this->numSignedDigits, $regex);
}
//time and zone regex patterns are same as extended
$this->formatRegex[C::FORMAT_EXTENDED_SIGNED][MatchDate::STRTIME] =
$this->formatRegex[C::FORMAT_EXTENDED][MatchDate::STRTIME];
$this->formatRegex[C::FORMAT_EXTENDED_SIGNED][MatchDate::STRZONE] =
$this->formatRegex[C::FORMAT_EXTENDED][MatchDate::STRZONE];
}
);
}
/**
* Validation
*
* @param mixed $value
* @return boolean True if value is valid else false
*/
protected function validate($value)
{
return Match::on(Option::create($this->validateISO($value), false))
->Monad_Option_Some(
function ($opt) use ($value) {
return Match::on(Option::create($opt->value() && $this->phpCheck, false))
->Monad_Option_Some(
function () use ($value) {
return Match::on(
FTry::with(
function () use ($value): void {
new \DateTime($value);
}
)
)
->Monad_FTry_Success(true)
->Monad_FTry_Failure(
function () {
$this->messenger->clear()->add(C::ERR_FAILED_PHP_CHECK);
return false;
}
)
->value();
}
)
->Monad_Option_None(
function () use ($opt) {
return $opt->value();
}
)
->value();
}
)
->Monad_Option_None(false)
->value();
}
/**
* Do the ISO8601 validation
*
* @param mixed $value
* @return boolean
*/
protected function validateISO($value)
{
//clear flags
$this->timepartFound = false;
$this->zonepartFound = false;
//clear message store
$this->messenger->clear();
//prep value
$value = strtolower($value);
$splitter = new SplitDate($this->laxTime, $this->laxZone, $this->format);
//ooh - how pythonesq!
[$date, $time, $zone, $this->timepartFound, $this->zonepartFound] = $splitter->splitDate($value);
if ($this->checkForNoDate($date)
|| $this->checkForEnforcement($time, $zone)
|| $this->checkForMissingParts($time, $zone)
) {
return false;
}
return $this->matchOnAvailableParts($date, $time, $zone);
}
/**
* @param $date
* @return boolean
*/
private function checkForNoDate($date)
{
return Match::on(Option::create($date))
->Monad_Option_None(
function () {
$this->messenger->add(C::ERR_REQ_DATE);
return true;
}
)
->Monad_Option_Some(false)
->value();
}
/**
* @param $time
* @param $zone
* @return bool
*/
private function checkForEnforcement($time, $zone)
{
return Match::on(Option::create($this->enforceTime && is_null($time), false))
->Monad_Option_Some(
function () {
$this->messenger->add(C::ERR_REQ_TIME);
return true;
}
)
->Monad_Option_None(
function () use ($zone) {
return Match::on(Option::create($this->enforceZone && is_null($zone), false))
->Monad_Option_Some(
function () {
$this->messenger->add(C::ERR_REQ_ZONE);
return true;
}
)
->Monad_Option_None(false)
->value();
}
)
->value();
}
/**
* @param $time
* @param $zone
* @return bool
*/
private function checkForMissingParts($time, $zone)
{
return Match::on(Option::create($this->timepartFound && is_null($time), false))
->Monad_Option_Some(
function () {
$this->messenger->add(C::ERR_TIME_NOTFOUND);
return true;
}
)
->Monad_Option_None(
function () use ($zone) {
return Match::on(Option::create($this->zonepartFound && is_null($zone), false))
->Monad_Option_Some(
function () {
$this->messenger->add(C::ERR_ZONE_NOTFOUND);
return true;
}
)
->Monad_Option_None(false)
->value();
}
)
->value();
}
/**
* @param $date
* @param $time
* @param $zone
* @return bool
*/
private function matchOnAvailableParts($date, $time, $zone)
{
$matcher = new MatchDate($this->format, $this->formatRegex, $this->messenger);
return Match::on(Option::create($this->timepartFound, false))
//has time part
->Monad_Option_Some(
function () use ($matcher, $date, $time, $zone) {
return Match::on(Option::create($this->zonepartFound, false))
//has zone part
->Monad_Option_Some(
function () use ($matcher, $date, $time, $zone) {
//date, time and zone
return Match::on(Option::create($matcher->matchDateAndTimeAndZone($date, $time, $zone), false))
->Monad_Option_Some(true)
->Monad_Option_None(
function () {
$this->messenger->clear()->add(C::ERR_INVALID);
return false;
}
)
->value();
}
)
//no zone part
->Monad_Option_None(
function () use ($matcher, $date, $time) {
//date and time
return Match::on(Option::create($matcher->matchDateAndTime($date, $time), false))
->Monad_Option_Some(true)
->Monad_Option_None(
function () {
$this->messenger->clear()->add(C::ERR_INVALID);
return false;
}
)
->value();
}
)
->value();
}
)
//no timepart
->Monad_Option_None(
function () use ($matcher, $date) {
//date only
return Match::on(Option::create($matcher->matchDate($date), false))
->Monad_Option_Some(true)
->Monad_Option_None(
function () {
$this->messenger->clear()->add(C::ERR_INVALID);
return false;
}
)
->value();
}
)
->value();
}
}