diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b14876..8e97589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/DateTime/DateIntervalFactory.php b/src/DateTime/DateIntervalFactory.php new file mode 100644 index 0000000..91bb219 --- /dev/null +++ b/src/DateTime/DateIntervalFactory.php @@ -0,0 +1,39 @@ + '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, + ] + ); + } +} diff --git a/src/DateTime/DateTimeImmutableFactory.php b/src/DateTime/DateTimeImmutableFactory.php index c2da6f0..473a764 100644 --- a/src/DateTime/DateTimeImmutableFactory.php +++ b/src/DateTime/DateTimeImmutableFactory.php @@ -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; } } @@ -126,9 +126,9 @@ 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; } @@ -136,4 +136,67 @@ public static function fromStrictFormat(string $value, string $format): \DateTim 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( + '/(?PT\d{2}:\d{2}:\d{2})(\.(?P\d+))?(?P[+-])/', + // 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 + ); + } + } diff --git a/test/unit/DateTime/DateIntervalFactoryTest.php b/test/unit/DateTime/DateIntervalFactoryTest.php new file mode 100644 index 0000000..4e48657 --- /dev/null +++ b/test/unit/DateTime/DateIntervalFactoryTest.php @@ -0,0 +1,31 @@ + [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()); + } + +} \ No newline at end of file diff --git a/test/unit/DateTime/DateIntervalUtilsTest.php b/test/unit/DateTime/DateIntervalUtilsTest.php new file mode 100644 index 0000000..44f7652 --- /dev/null +++ b/test/unit/DateTime/DateIntervalUtilsTest.php @@ -0,0 +1,51 @@ +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); + } + +} diff --git a/test/unit/DateTime/DateTimeImmutableFactoryTest.php b/test/unit/DateTime/DateTimeImmutableFactoryTest.php index 3538d8d..b134c4e 100644 --- a/test/unit/DateTime/DateTimeImmutableFactoryTest.php +++ b/test/unit/DateTime/DateTimeImmutableFactoryTest.php @@ -7,6 +7,8 @@ namespace test\unit\Ingenerator\PHPUtils\DateTime; +use DateTimeImmutable; +use Ingenerator\PHPUtils\DateTime\DateString; use Ingenerator\PHPUtils\DateTime\DateTimeImmutableFactory; use Ingenerator\PHPUtils\DateTime\InvalidUserDateTime; use PHPUnit\Framework\TestCase; @@ -23,7 +25,7 @@ class DateTimeImmutableFactoryTest extends TestCase public function test_it_factories_correct_object_from_valid_user_date_input($input, $expect) { $actual = DateTimeImmutableFactory::fromUserDateInput($input); - $this->assertInstanceOf(\DateTimeImmutable::class, $actual); + $this->assertInstanceOf(DateTimeImmutable::class, $actual); $this->assertNotInstanceOf(InvalidUserDateTime::class, $actual); $this->assertEquals($expect.' 00:00:00', $actual->format('Y-m-d H:i:s')); } @@ -35,7 +37,8 @@ public function test_it_factories_correct_object_from_valid_user_date_input($inp public function test_it_factories_null_from_empty_user_date_input($input) { $this->assertNull( - DateTimeImmutableFactory::fromUserDateInput($input)); + DateTimeImmutableFactory::fromUserDateInput($input) + ); } /** @@ -61,7 +64,7 @@ public function test_it_factories_correct_object_from_valid_user_date_time_input $expect ) { $actual = DateTimeImmutableFactory::fromUserDateTimeInput($input); - $this->assertInstanceOf(\DateTimeImmutable::class, $actual); + $this->assertInstanceOf(DateTimeImmutable::class, $actual); $this->assertNotInstanceOf(InvalidUserDateTime::class, $actual); $this->assertEquals($expect, $actual->format('Y-m-d H:i:s')); } @@ -93,7 +96,7 @@ public function test_it_factories_invalid_object_from_invalid_user_date_time_inp public function test_it_factories_correct_object_from_valid_ymd_input($input, $expect) { $actual = DateTimeImmutableFactory::fromYmdInput($input); - $this->assertInstanceOf(\DateTimeImmutable::class, $actual); + $this->assertInstanceOf(DateTimeImmutable::class, $actual); $this->assertNotInstanceOf(InvalidUserDateTime::class, $actual); $this->assertEquals($expect, $actual->format('Y-m-d H:i:s')); @@ -130,7 +133,7 @@ public function test_it_factories_correct_object_from_valid_ymdhis_in_default_tz try { \date_default_timezone_set('Europe/London'); $actual = DateTimeImmutableFactory::fromYmdHis($input); - $this->assertInstanceOf(\DateTimeImmutable::class, $actual); + $this->assertInstanceOf(DateTimeImmutable::class, $actual); $this->assertSame('Europe/London', $actual->getTimezone()->getName()); $this->assertSame($expect, $actual->format('Y-m-d\TH:i:s.uP')); } finally { @@ -192,13 +195,13 @@ public function test_it_factories_from_microtime_in_default_tz($val, $expect) public function test_it_factories_from_strict_date_format(string $val, string $format, string $expect): void { $actual = DateTimeImmutableFactory::fromStrictFormat($val, $format); - $this->assertInstanceOf(\DateTimeImmutable::class, $actual); + $this->assertInstanceOf(DateTimeImmutable::class, $actual); $this->assertSame($expect, $actual->format('Y-m-d\TH:i:s.uP')); } /** * @testWith ["01-02-2020 10:20:30", "Y-m-d H:i:s"] - * @testWith ["2021-02-23 15:16:17", "Y-m-d\TH:i:s.uP"] + * ["2021-02-23 15:16:17", "Y-m-d\\TH:i:s.uP"] */ public function test_it_throws_from_strict_date_format(string $val, string $format): void { @@ -207,4 +210,71 @@ public function test_it_throws_from_strict_date_format(string $val, string $form DateTimeImmutableFactory::fromStrictFormat($val, $format); } + public function provider_from_iso_ms() + { + return [ + '6 millis in +00:00' => ['2023-02-02T10:03:02.123456+00:00', '2023-02-02T10:03:02.123456+00:00'], + '6 millis in +01:00' => ['2024-01-10T12:23:42.456789+01:00', '2024-01-10T12:23:42.456789+01:00'], + '6 millis in -06:30' => ['2023-04-30T15:56:15.987654-06:30', '2023-04-30T15:56:15.987654-06:30'], + '6 millis, Zulu time' => ['2023-02-02T10:03:02.123456Z', '2023-02-02T10:03:02.123456+00:00'], + '6 millis, zulu time' => ['2023-02-02T10:03:02.123456z', '2023-02-02T10:03:02.123456+00:00'], + 'without millis in +01:00' => ['2023-04-30T15:56:15+01:00', '2023-04-30T15:56:15.000000+01:00'], + 'without millis in zulu' => ['2023-04-30T15:56:15Z', '2023-04-30T15:56:15.000000+00:00'], + '3-digit millis in zulu' => ['2023-04-30T15:56:15.123Z', '2023-04-30T15:56:15.123000+00:00'], + '9-digit millis in zulu' => ['2023-04-30T15:56:15.1234561239Z', '2023-04-30T15:56:15.123456+00:00'], + '8-digit millis numeric tz' => ['2023-04-30T15:56:15.12345642-03:30', '2023-04-30T15:56:15.123456-03:30'], + // We don't want to round, because if this represents "now" we don't want to risk getting even a ms into the + // future. Also, lower-precision clocks don't round time, they wait till the next full tick and roll over + 'extra millis are truncated' => ['2023-04-30T15:56:15.1234566-03:30', '2023-04-30T15:56:15.123456-03:30'], + ]; + } + + /** + * @dataProvider provider_from_iso_ms + */ + public function test_it_factories_from_iso_format(string $input, string $expect) + { + $actual = DateTimeImmutableFactory::fromIso($input); + $this->assertSame($expect, DateString::isoMS($actual)); + } + + public function provider_throws_from_invalid_iso() + { + return [ + 'missing T separator' => ['2023-04-30 15:56:15.12345642-03:30'], + 'named timezone' => ['2023-04-30T15:56:15.12345642 Europe/London'], + 'nonsense date' => ['2023-02-31T15:56:15.12345642+00:00'], + 'nonsense date, zulu' => ['2023-02-31T15:56:15.12345642Z'], + 'nonsense time' => ['2023-02-28T45:23:59+00:00'], + ]; + } + + /** + * @dataProvider provider_throws_from_invalid_iso + */ + public function test_it_throws_from_invalid_iso_format($input) + { + $this->expectExceptionMessage("`$input` cannot be parsed as a valid ISO date-time"); + $this->expectException(\InvalidArgumentException::class); + DateTimeImmutableFactory::fromIso($input); + } + + public function test_it_can_factory_with_zero_micros() + { + $result = DateTimeImmutableFactory::zeroMicros( + DateTimeImmutableFactory::fromIso('2023-01-03T10:02:03.123456+01:00') + ); + $this->assertSame('2023-01-03T10:02:03.000000+01:00', DateString::isoMS($result)); + } + + public function test_zero_micros_uses_current_time_by_default() + { + $before = DateTimeImmutableFactory::fromYmdHis(date('Y-m-d H:i:s')); + $result = DateTimeImmutableFactory::zeroMicros(); + $after = new DateTimeImmutable(); + $this->assertSame('000000', $result->format('u'), 'Micros are zero'); + $this->assertLessThanOrEqual($after, $result, 'Should be before now'); + $this->assertGreaterThanOrEqual($before, $result, 'Should be after start of test (ignoring micros)'); + } + }