Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
### Unreleased

### v1.20.0 (2023-10-17)

* Add DateIntervalUtils to stringify a DateInterval, and DateIntervalFactory for shorthand creation
* Add DateTimeImmutableFactory::zeroMicros() to create / modify a DT with microseconds truncated to zero
* Add DateTimeImmutableFactory::fromIso() to strictly parse ISO 8601 / RFC 3339 date-time strings
* Fix bug where devices with long user agents generated a database exception when attempting to create a session.

### v1.19.2 (2023-04-27)
Expand Down
39 changes: 39 additions & 0 deletions src/DateTime/DateIntervalFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace Ingenerator\PHPUtils\DateTime;

use DateInterval;

class DateIntervalFactory
{

public static function days(int $days): DateInterval
{
return new DateInterval('P'.$days.'D');
}

public static function hours(int $int): DateInterval
{
return new DateInterval('PT'.$int.'H');
}

public static function minutes(int $int): DateInterval
{
return new DateInterval('PT'.$int.'M');
}

public static function months(int $int): DateInterval
{
return new DateInterval('P'.$int.'M');
}

public static function seconds(int $seconds): DateInterval
{
return new DateInterval('PT'.$seconds.'S');
}

public static function years(int $int): DateInterval
{
return new DateInterval('P'.$int.'Y');
}
}
58 changes: 58 additions & 0 deletions src/DateTime/DateIntervalUtils.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

namespace Ingenerator\PHPUtils\DateTime;

use DateInterval;
use function array_diff;
use function array_filter;
use function array_intersect_key;
use function array_keys;
use function array_pop;
use function count;
use function implode;

class DateIntervalUtils
{
private const SUPPORTED_HUMAN_COMPONENTS = [
'y' => 'year',
'm' => 'month',
'd' => 'day',
'h' => 'hour',
'i' => 'minute',
's' => 'second',
];

public static function toHuman(DateInterval $interval): string
{
$components = array_filter((array) $interval);

$supported_components = array_intersect_key(static::SUPPORTED_HUMAN_COMPONENTS, $components);

if (count($supported_components) !== count($components)) {
$key_list = implode(', ', array_diff(array_keys($components), array_keys($supported_components)));
throw new \InvalidArgumentException(
'Cannot humanise a DateInterval with unsupported components: '.$key_list
);
}

$parts = [];
foreach ($supported_components as $key => $human_value) {
$qty = $components[$key] ?? NULL;
$parts[] = $qty.' '.$human_value.($qty > 1 ? 's' : '');
}

$last_part = array_pop($parts);
$second_last = array_pop($parts);

return implode(
'',
[
implode(', ', $parts),
$parts !== [] ? ', ' : '',
$second_last,
$second_last ? ' and ' : '',
$last_part,
]
);
}
}
69 changes: 66 additions & 3 deletions src/DateTime/DateTimeImmutableFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ protected static function fromPossibleFormats($input, array $formats)
}
foreach ($formats as $format) {
$date = DateTimeImmutable::createFromFormat('!'.$format, $input);
if ($date AND $date->format($format) === $input) {
if ($date and $date->format($format) === $input) {
return $date;
}
}
Expand Down Expand Up @@ -126,14 +126,77 @@ public static function fromYmdHis(string $input): DateTimeImmutable
throw new \InvalidArgumentException($input.' is not in the format Y-m-d H:i:s');
}

public static function fromStrictFormat(string $value, string $format): \DateTimeImmutable
public static function fromStrictFormat(string $value, string $format): DateTimeImmutable
{
$date = \DateTimeImmutable::createFromFormat('!'.$format, $value);
$date = DateTimeImmutable::createFromFormat('!'.$format, $value);
if ($date && ($date->format($format) === $value)) {
return $date;
}

throw new \InvalidArgumentException("`$value` is not a valid date/time in the format `$format`");
}

/**
* Parses a time string in full ISO 8601 / RFC3339 format with optional milliseconds and timezone offset
*
* Can parse strings with any millisecond precision, truncating anything beyond 6 digits (which is the maximum
* precision PHP supports). Copes with either `Z` or `+00:00` for the UTC timezone.
*
* Example valid inputs:
* - 2023-05-03T10:02:03Z
* - 2023-05-03T10:02:03.123456Z
* - 2023-05-03T10:02:03.123456789Z
* - 2023-05-03T10:02:03.123456789+01:00
* - 2023-05-03T10:02:03.123456789-01:30
*
* @param string $value
*
* @return DateTimeImmutable
*/
public static function fromIso(string $value): DateTimeImmutable
{
// Cope with Z for Zulu time instead of +00:00 - PHP offers `p` for this, but that then doesn't accept '+00:00'
$fixed_value = preg_replace('/Z/i', '+00:00', $value);

// Pad / truncate milliseconds to 6 digits as that's the precision PHP can support
// Regex is a bit dull here, but we need to be sure we can reliably find the (possibly absent)
// millisecond segment without the risk of modifying unexpected parts of the string especially in
// invalid values. Note that this will always replace the millis even in a 6-digit string, but it's simpler
// than making the regex test for 0-5 or 7+ digits.
$fixed_value = preg_replace_callback(
'/(?P<hms>T\d{2}:\d{2}:\d{2})(\.(?P<millis>\d+))?(?P<tz_prefix>[+-])/',
// Can't use sprintf because we want to truncate the milliseconds, not round them
// So it's simpler to just handle this as a string and cut / pad as required.
fn($matches) => $matches['hms']
.'.'
.substr(str_pad($matches['millis'], 6, '0'), 0, 6)
.$matches['tz_prefix'],
$fixed_value
);

// Not using fromStrictFormat as I want to throw with the original value, not the parsed value
$date = DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:s.uP', $fixed_value);
if (DateString::isoMS($date ?: NULL) === $fixed_value) {
return $date;
}
throw new \InvalidArgumentException("`$value` cannot be parsed as a valid ISO date-time");
}

/**
* Remove microseconds from a time (or current time, if nothing passed)
*
* @param DateTimeImmutable $time
*
* @return DateTimeImmutable
*/
public static function zeroMicros(DateTimeImmutable $time = new DateTimeImmutable()): DateTimeImmutable
{
return $time->setTime(
hour: $time->format('H'),
minute: $time->format('i'),
second: $time->format('s'),
microsecond: 0
);
}

}
31 changes: 31 additions & 0 deletions test/unit/DateTime/DateIntervalFactoryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace test\unit\Ingenerator\PHPUtils\DateTime;

use DateInterval;
use Ingenerator\PHPUtils\DateTime\DateIntervalFactory;
use PHPUnit\Framework\TestCase;

class DateIntervalFactoryTest extends TestCase
{
public function provider_shorthand_single_part()
{
return [
'seconds' => [fn() => DateIntervalFactory::seconds(15), new DateInterval('PT15S')],
'minutes' => [fn() => DateIntervalFactory::minutes(3), new DateInterval('PT3M')],
'hours' => [fn() => DateIntervalFactory::hours(5), new DateInterval('PT5H')],
'days' => [fn() => DateIntervalFactory::days(24), new DateInterval('P24D')],
'months' => [fn() => DateIntervalFactory::months(14), new DateInterval('P14M')],
'years' => [fn() => DateIntervalFactory::years(2), new DateInterval('P2Y')],
];
}

/**
* @dataProvider provider_shorthand_single_part
*/
public function test_from_shorthand_single_part(callable $creator, DateInterval $expect)
{
$this->assertEquals($expect, $creator());
}

}
51 changes: 51 additions & 0 deletions test/unit/DateTime/DateIntervalUtilsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace test\unit\Ingenerator\PHPUtils\DateTime;

use DateTimeImmutable;
use Ingenerator\PHPUtils\DateTime\DateIntervalUtils;
use PHPUnit\Framework\TestCase;

class DateIntervalUtilsTest extends TestCase
{

/**
* @testWith ["P5M", "5 months"]
* ["P1Y", "1 year"]
* ["P10Y", "10 years"]
* ["P3W", "21 days", "NOTE: Weeks is always compiled-out to days in the object, cannot get back to it."]
* ["P3W2D", "23 days", "NOTE: Weeks is always compiled-out to days in the object, cannot get back to it."]
* ["P1Y3M", "1 year and 3 months"]
* ["P2Y3M2D", "2 years, 3 months and 2 days"]
* ["PT4H", "4 hours"]
* ["P3DT4H", "3 days and 4 hours"]
* ["PT5M4S", "5 minutes and 4 seconds"]
*/
public function test_it_can_parse_to_human_string(string $interval_string, string $expect): void
{
$this->assertSame(
$expect,
DateIntervalUtils::toHuman(new \DateInterval($interval_string))
);
}

public function provider_unsupported_human_intervals()
{
$diff = fn(string $dt1, string $dt2) => (new DateTimeImmutable($dt1))->diff(new DateTimeImmutable($dt2));

return [
'with micros e.g. from a diff' => [$diff('now', 'now')],
'negative' => [$diff('2022-01-01 10:03:03', '2021-03-02 10:02:03')],
];
}

/**
* @dataProvider provider_unsupported_human_intervals
*/
public function test_to_human_throws_with_unsupported_intervals(\DateInterval $interval)
{
$this->expectException(\InvalidArgumentException::class);
DateIntervalUtils::toHuman($interval);
}

}
Loading