Skip to content

Commit 7fdedf1

Browse files
authored
Merge pull request #57 from ingenerator/1.x-feat-date-utils
Add some more date / dateinterval utility functions
2 parents a81ffb1 + fe1c41d commit 7fdedf1

File tree

7 files changed

+327
-10
lines changed

7 files changed

+327
-10
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
### Unreleased
22

3+
### v1.20.0 (2023-10-17)
4+
5+
* Add DateIntervalUtils to stringify a DateInterval, and DateIntervalFactory for shorthand creation
6+
* Add DateTimeImmutableFactory::zeroMicros() to create / modify a DT with microseconds truncated to zero
7+
* Add DateTimeImmutableFactory::fromIso() to strictly parse ISO 8601 / RFC 3339 date-time strings
38
* Fix bug where devices with long user agents generated a database exception when attempting to create a session.
49

510
### v1.19.2 (2023-04-27)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace Ingenerator\PHPUtils\DateTime;
4+
5+
use DateInterval;
6+
7+
class DateIntervalFactory
8+
{
9+
10+
public static function days(int $days): DateInterval
11+
{
12+
return new DateInterval('P'.$days.'D');
13+
}
14+
15+
public static function hours(int $int): DateInterval
16+
{
17+
return new DateInterval('PT'.$int.'H');
18+
}
19+
20+
public static function minutes(int $int): DateInterval
21+
{
22+
return new DateInterval('PT'.$int.'M');
23+
}
24+
25+
public static function months(int $int): DateInterval
26+
{
27+
return new DateInterval('P'.$int.'M');
28+
}
29+
30+
public static function seconds(int $seconds): DateInterval
31+
{
32+
return new DateInterval('PT'.$seconds.'S');
33+
}
34+
35+
public static function years(int $int): DateInterval
36+
{
37+
return new DateInterval('P'.$int.'Y');
38+
}
39+
}

src/DateTime/DateIntervalUtils.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
namespace Ingenerator\PHPUtils\DateTime;
4+
5+
use DateInterval;
6+
use function array_diff;
7+
use function array_filter;
8+
use function array_intersect_key;
9+
use function array_keys;
10+
use function array_pop;
11+
use function count;
12+
use function implode;
13+
14+
class DateIntervalUtils
15+
{
16+
private const SUPPORTED_HUMAN_COMPONENTS = [
17+
'y' => 'year',
18+
'm' => 'month',
19+
'd' => 'day',
20+
'h' => 'hour',
21+
'i' => 'minute',
22+
's' => 'second',
23+
];
24+
25+
public static function toHuman(DateInterval $interval): string
26+
{
27+
$components = array_filter((array) $interval);
28+
29+
$supported_components = array_intersect_key(static::SUPPORTED_HUMAN_COMPONENTS, $components);
30+
31+
if (count($supported_components) !== count($components)) {
32+
$key_list = implode(', ', array_diff(array_keys($components), array_keys($supported_components)));
33+
throw new \InvalidArgumentException(
34+
'Cannot humanise a DateInterval with unsupported components: '.$key_list
35+
);
36+
}
37+
38+
$parts = [];
39+
foreach ($supported_components as $key => $human_value) {
40+
$qty = $components[$key] ?? NULL;
41+
$parts[] = $qty.' '.$human_value.($qty > 1 ? 's' : '');
42+
}
43+
44+
$last_part = array_pop($parts);
45+
$second_last = array_pop($parts);
46+
47+
return implode(
48+
'',
49+
[
50+
implode(', ', $parts),
51+
$parts !== [] ? ', ' : '',
52+
$second_last,
53+
$second_last ? ' and ' : '',
54+
$last_part,
55+
]
56+
);
57+
}
58+
}

src/DateTime/DateTimeImmutableFactory.php

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ protected static function fromPossibleFormats($input, array $formats)
8484
}
8585
foreach ($formats as $format) {
8686
$date = DateTimeImmutable::createFromFormat('!'.$format, $input);
87-
if ($date AND $date->format($format) === $input) {
87+
if ($date and $date->format($format) === $input) {
8888
return $date;
8989
}
9090
}
@@ -126,14 +126,77 @@ public static function fromYmdHis(string $input): DateTimeImmutable
126126
throw new \InvalidArgumentException($input.' is not in the format Y-m-d H:i:s');
127127
}
128128

129-
public static function fromStrictFormat(string $value, string $format): \DateTimeImmutable
129+
public static function fromStrictFormat(string $value, string $format): DateTimeImmutable
130130
{
131-
$date = \DateTimeImmutable::createFromFormat('!'.$format, $value);
131+
$date = DateTimeImmutable::createFromFormat('!'.$format, $value);
132132
if ($date && ($date->format($format) === $value)) {
133133
return $date;
134134
}
135135

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

139+
/**
140+
* Parses a time string in full ISO 8601 / RFC3339 format with optional milliseconds and timezone offset
141+
*
142+
* Can parse strings with any millisecond precision, truncating anything beyond 6 digits (which is the maximum
143+
* precision PHP supports). Copes with either `Z` or `+00:00` for the UTC timezone.
144+
*
145+
* Example valid inputs:
146+
* - 2023-05-03T10:02:03Z
147+
* - 2023-05-03T10:02:03.123456Z
148+
* - 2023-05-03T10:02:03.123456789Z
149+
* - 2023-05-03T10:02:03.123456789+01:00
150+
* - 2023-05-03T10:02:03.123456789-01:30
151+
*
152+
* @param string $value
153+
*
154+
* @return DateTimeImmutable
155+
*/
156+
public static function fromIso(string $value): DateTimeImmutable
157+
{
158+
// Cope with Z for Zulu time instead of +00:00 - PHP offers `p` for this, but that then doesn't accept '+00:00'
159+
$fixed_value = preg_replace('/Z/i', '+00:00', $value);
160+
161+
// Pad / truncate milliseconds to 6 digits as that's the precision PHP can support
162+
// Regex is a bit dull here, but we need to be sure we can reliably find the (possibly absent)
163+
// millisecond segment without the risk of modifying unexpected parts of the string especially in
164+
// invalid values. Note that this will always replace the millis even in a 6-digit string, but it's simpler
165+
// than making the regex test for 0-5 or 7+ digits.
166+
$fixed_value = preg_replace_callback(
167+
'/(?P<hms>T\d{2}:\d{2}:\d{2})(\.(?P<millis>\d+))?(?P<tz_prefix>[+-])/',
168+
// Can't use sprintf because we want to truncate the milliseconds, not round them
169+
// So it's simpler to just handle this as a string and cut / pad as required.
170+
fn($matches) => $matches['hms']
171+
.'.'
172+
.substr(str_pad($matches['millis'], 6, '0'), 0, 6)
173+
.$matches['tz_prefix'],
174+
$fixed_value
175+
);
176+
177+
// Not using fromStrictFormat as I want to throw with the original value, not the parsed value
178+
$date = DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:s.uP', $fixed_value);
179+
if (DateString::isoMS($date ?: NULL) === $fixed_value) {
180+
return $date;
181+
}
182+
throw new \InvalidArgumentException("`$value` cannot be parsed as a valid ISO date-time");
183+
}
184+
185+
/**
186+
* Remove microseconds from a time (or current time, if nothing passed)
187+
*
188+
* @param DateTimeImmutable $time
189+
*
190+
* @return DateTimeImmutable
191+
*/
192+
public static function zeroMicros(DateTimeImmutable $time = new DateTimeImmutable()): DateTimeImmutable
193+
{
194+
return $time->setTime(
195+
hour: $time->format('H'),
196+
minute: $time->format('i'),
197+
second: $time->format('s'),
198+
microsecond: 0
199+
);
200+
}
201+
139202
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace test\unit\Ingenerator\PHPUtils\DateTime;
4+
5+
use DateInterval;
6+
use Ingenerator\PHPUtils\DateTime\DateIntervalFactory;
7+
use PHPUnit\Framework\TestCase;
8+
9+
class DateIntervalFactoryTest extends TestCase
10+
{
11+
public function provider_shorthand_single_part()
12+
{
13+
return [
14+
'seconds' => [fn() => DateIntervalFactory::seconds(15), new DateInterval('PT15S')],
15+
'minutes' => [fn() => DateIntervalFactory::minutes(3), new DateInterval('PT3M')],
16+
'hours' => [fn() => DateIntervalFactory::hours(5), new DateInterval('PT5H')],
17+
'days' => [fn() => DateIntervalFactory::days(24), new DateInterval('P24D')],
18+
'months' => [fn() => DateIntervalFactory::months(14), new DateInterval('P14M')],
19+
'years' => [fn() => DateIntervalFactory::years(2), new DateInterval('P2Y')],
20+
];
21+
}
22+
23+
/**
24+
* @dataProvider provider_shorthand_single_part
25+
*/
26+
public function test_from_shorthand_single_part(callable $creator, DateInterval $expect)
27+
{
28+
$this->assertEquals($expect, $creator());
29+
}
30+
31+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace test\unit\Ingenerator\PHPUtils\DateTime;
4+
5+
use DateTimeImmutable;
6+
use Ingenerator\PHPUtils\DateTime\DateIntervalUtils;
7+
use PHPUnit\Framework\TestCase;
8+
9+
class DateIntervalUtilsTest extends TestCase
10+
{
11+
12+
/**
13+
* @testWith ["P5M", "5 months"]
14+
* ["P1Y", "1 year"]
15+
* ["P10Y", "10 years"]
16+
* ["P3W", "21 days", "NOTE: Weeks is always compiled-out to days in the object, cannot get back to it."]
17+
* ["P3W2D", "23 days", "NOTE: Weeks is always compiled-out to days in the object, cannot get back to it."]
18+
* ["P1Y3M", "1 year and 3 months"]
19+
* ["P2Y3M2D", "2 years, 3 months and 2 days"]
20+
* ["PT4H", "4 hours"]
21+
* ["P3DT4H", "3 days and 4 hours"]
22+
* ["PT5M4S", "5 minutes and 4 seconds"]
23+
*/
24+
public function test_it_can_parse_to_human_string(string $interval_string, string $expect): void
25+
{
26+
$this->assertSame(
27+
$expect,
28+
DateIntervalUtils::toHuman(new \DateInterval($interval_string))
29+
);
30+
}
31+
32+
public function provider_unsupported_human_intervals()
33+
{
34+
$diff = fn(string $dt1, string $dt2) => (new DateTimeImmutable($dt1))->diff(new DateTimeImmutable($dt2));
35+
36+
return [
37+
'with micros e.g. from a diff' => [$diff('now', 'now')],
38+
'negative' => [$diff('2022-01-01 10:03:03', '2021-03-02 10:02:03')],
39+
];
40+
}
41+
42+
/**
43+
* @dataProvider provider_unsupported_human_intervals
44+
*/
45+
public function test_to_human_throws_with_unsupported_intervals(\DateInterval $interval)
46+
{
47+
$this->expectException(\InvalidArgumentException::class);
48+
DateIntervalUtils::toHuman($interval);
49+
}
50+
51+
}

0 commit comments

Comments
 (0)