Zwirbelbart
Zwirbelbart

Reputation: 827

Format DateInterval as ISO8601

I am currently working on a php project and need to format a DateInterval as ISO8601 (something like this):

P5D

This format can be used to create DateTime and DateInterval objects, but I can't figure out a way to format a DateInterval into this format. Is there any? If not, what might be a lightweight solution to that?

Upvotes: 7

Views: 3591

Answers (3)

Will B.
Will B.

Reputation: 18416

To expand on the solution provided by Dave and the solution provided by Guss.

A workaround to add the T portion of the duration of the ISO 8601 standard and not need to default as P0M, is to insert 2 additional checks after the P0Y replaced by Y.

  • Y0M replaced by Y
  • P0M replaced by P
  • change D0H to TD0H replaced by DT
  • remove redundant Y0M replaced by Y0M

This works because the interval format will always generate the same structure, without leading 0's and str_replace walks through each replacement in the arrays from left to right. As a result there is a possibility that we could receive P1YT or PT as a return value. Simply remove any trailing PT characters by using rtrim. Then substitute an empty value with a desired default value, as in the answer by Guss.

ISO 8601 Assertions: https://3v4l.org/c55kL

Compatible with PHP 5.3+

The use of the static keyword is to improve performance of repeated calls to the function, as the static variables do not lose their values after leaving the function scope

function date_interval_iso(DateInterval $interval, $default = 'PT0S') {
    static $f = array('M0S', 'H0M', 'DT0H', 'M0D', 'P0Y', 'Y0M', 'P0M');
    static $r = array('M', 'H', 'DT', 'M', 'P', 'Y', 'P');

    return rtrim(str_replace($f, $r, $interval->format('P%yY%mM%dDT%hH%iM%sS')), 'PT') ?: $default;
}

Test

To compare I created an array containing every possible duration combination (excluding microseconds) with a value of 1. Which can be viewed in the example link above.

$durations ['P1Y', /*...*/ 'P1Y1M1DT1H1M1S'];
$isos = array();
foreach ($durations as $duration) {
    $isos[] = date_interval_iso(new DateInterval($duration));
}

$diff = array_diff($durations, $isos);
if (!empty($diff)) {
    //output any differences
    var_dump($diff);
}

//test 0 duration DateInterval 
$date1 = new DateTime();
echo date_interval_iso($date1->diff($date1));

Result

PT0S

PHP 7.1+ with Microseconds https://3v4l.org/VFN7N

You can add microseconds easily, by including S0F replaced by S of the first array values and adding %fF to your DateInterval::format().

However F (microseconds) is not currently a supported interval specification for DateInterval::__construct() or the ISO 8601 standard.

NOTE: there was a bug in PHP <= 7.2.13 with DateTime::diff that prevents the proper DateInterval retrieval when the difference was less than one second.

function date_interval_iso(DateInterval $interval, string $default = 'PT0F') {
    static $f = ['S0F', 'M0S', 'H0M', 'DT0H', 'M0D', 'P0Y', 'Y0M', 'P0M'];
    static $r = ['S', 'M', 'H', 'DT', 'M', 'P', 'Y', 'P'];

    return rtrim(str_replace($f, $r, $interval->format('P%yY%mM%dDT%hH%iM%sS%fF')), 'PT') ?: $default;
}

Test

$date1 = new DateTimeImmutable();
$date2 = new DateTimeImmutable();

//test 0 duration DateInterval 
echo date_interval_iso($date1->diff($date1));

//test microseconds
echo date_interval_iso($date2->diff($date1));

Result

PT0F
PT21F

Upvotes: 5

Guss
Guss

Reputation: 32364

With regards to @dave, I re-implemented his solution in a way that solves some of the problems, specifically the requirement to always leave a month field. From the Wikipedia article on ISO-8601, it doesn't seem like the T designation is optional, even through its completely missing from the above mentioned implementation. By introducing it we can solve most of our problems and make for some cleaner code:

function date_interval_iso_format(DateInterval $interval) {
    list($date,$time) = explode("T",$interval->format("P%yY%mM%dDT%hH%iM%sS"));
    // now, we need to remove anything that is a zero, but make sure to not remove
    // something like 10D or 20D
    $res =
        str_replace([ 'M0D', 'Y0M', 'P0Y' ], [ 'M', 'Y', 'P' ], $date) .
        rtrim(str_replace([ 'M0S', 'H0M', 'T0H'], [ 'M', 'H', 'T' ], "T$time"),"T");
    if ($res == 'P') // edge case - if we remove everything, DateInterval will hate us later
        return 'PT0S';
    return $res;
}

Note that we remove the T if it is not needed, so now both uses of M work fine:

"P5M" == date_interval_iso_format(new DateInterval("P5M")); // => true
"PT5M" == date_interval_iso_format(new DateInterval("PT5M")); // => true

Upvotes: 3

dave
dave

Reputation: 64677

Well, if you look at the spec for the format when you construct one:

Y years

M months

D days

W weeks. These get converted into days, so can not be combined with D.

H hours

M minutes

S seconds

Then look at what you have to work with (http://php.net/manual/en/dateinterval.format.php), it seems like what you would do is:

$dateInterval = new DateInterval( /* whatever */ );
$format = $dateInterval->format("P%yY%mM%dD%hH%iM%sS");
//P0Y0M5D0H0M0S
//now, we need to remove anything that is a zero, but make sure to not remove
//something like 10D or 20D
$format = str_replace(["M0S", "H0M", "D0H", "M0D", "Y0M", "P0Y"], ["M", "H", "D", "M", "Y0M", "P"], $format);
echo $format;
//P0M5D

Now, the one thing I did differently is I always include the months, even if it is 0. The reason for this is that minutes and months are both represented by M - if we always include the month, then if there is a minute we know it is minutes. Otherwise we have to do a bunch of logic to see if we need to change the P to PT so it knows that the a M in this instance stands for Minute.

For example:

// For 3 Months
new DateInterval("P3M");
// For 3 Minutes
new DateInterval("PT3M"));

But instead we do:

// For 3 Months
new DateInterval("P3M");
// For 3 Minutes
new DateInterval("P0M3M"));

Upvotes: 2

Related Questions