Skip to content

Commit 93101ba

Browse files
committed
Auto-append pipe modifier in createFromFormat for deterministic behavior
Unlike PHP's native DateTimeImmutable::createFromFormat(), this method now automatically appends the | modifier if no reset modifier (| or \!) is present. This ensures that unparsed components are reset to zero instead of being filled from the current time. This provides more predictable and deterministic behavior, especially useful for testing where flaky tests can occur due to missing components being filled with the current system time. Examples: - createFromFormat('Y-m-d', '2024-03-14') -> time is 00:00:00 (not current) - createFromFormat('Y-m-d H:i', '2024-03-14 12:30') -> seconds is 00 This is a behavior change from PHP's native method, but arguably the more intuitive default.
1 parent 7a3db17 commit 93101ba

2 files changed

Lines changed: 57 additions & 169 deletions

File tree

src/Chronos.php

Lines changed: 12 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,12 @@ public static function createFromTime(
668668
/**
669669
* Create an instance from a specific format
670670
*
671+
* Unlike PHP's native DateTimeImmutable::createFromFormat(), this method
672+
* automatically appends the `|` modifier if no reset modifier (`|` or `!`)
673+
* is present. This ensures that unparsed components are reset to zero
674+
* instead of being filled from the current time, providing more predictable
675+
* and deterministic behavior.
676+
*
671677
* @param string $format The date() compatible format string.
672678
* @param string $time The formatted date string to interpret.
673679
* @param \DateTimeZone|string|null $timezone The DateTimeZone object or timezone name the new instance should use.
@@ -679,6 +685,12 @@ public static function createFromFormat(
679685
string $time,
680686
DateTimeZone|string|null $timezone = null,
681687
): static {
688+
// Auto-append | modifier if no reset modifier is present
689+
// This ensures unparsed components are zero instead of current time
690+
if (!str_contains($format, '|') && !str_contains($format, '!')) {
691+
$format .= '|';
692+
}
693+
682694
if ($timezone !== null) {
683695
$dateTime = parent::createFromFormat($format, $time, $timezone ? static::safeCreateDateTimeZone($timezone) : null);
684696
} else {
@@ -692,98 +704,9 @@ public static function createFromFormat(
692704
throw new InvalidArgumentException($message);
693705
}
694706

695-
$testNow = static::getTestNow();
696-
if ($testNow !== null) {
697-
$dateTime = static::applyTestNowToMissingComponents($dateTime, $format, $testNow);
698-
}
699-
700-
return $dateTime;
701-
}
702-
703-
/**
704-
* Apply testNow values to date/time components that weren't in the format string.
705-
*
706-
* @param static $dateTime The parsed datetime instance.
707-
* @param string $format The format string used for parsing.
708-
* @param \Cake\Chronos\Chronos $testNow The test now instance.
709-
* @return static
710-
*/
711-
protected static function applyTestNowToMissingComponents(
712-
self $dateTime,
713-
string $format,
714-
Chronos $testNow,
715-
): static {
716-
// Parse format string to find which characters are actual format specifiers (not escaped)
717-
$formatChars = static::getFormatCharacters($format);
718-
719-
// Check which components are present in the format
720-
$hasYear = (bool)array_intersect($formatChars, ['Y', 'y', 'o', 'X', 'x']);
721-
$hasMonth = (bool)array_intersect($formatChars, ['m', 'n', 'M', 'F']);
722-
$hasDay = (bool)array_intersect($formatChars, ['d', 'j', 'D', 'l', 'N', 'z', 'w', 'W', 'S']);
723-
$hasHour = (bool)array_intersect($formatChars, ['H', 'G', 'h', 'g']);
724-
$hasMinute = (bool)array_intersect($formatChars, ['i']);
725-
$hasSecond = (bool)array_intersect($formatChars, ['s']);
726-
$hasMicro = (bool)array_intersect($formatChars, ['u', 'v']);
727-
728-
// If the format includes '!' or '|', PHP resets unspecified components to Unix epoch or zero
729-
// If 'U' is present, all components are set from the Unix timestamp
730-
// In these cases, we should not override with testNow
731-
$hasReset = in_array('!', $formatChars, true) || in_array('|', $formatChars, true);
732-
$hasUnixTimestamp = in_array('U', $formatChars, true);
733-
if ($hasReset || $hasUnixTimestamp) {
734-
return $dateTime;
735-
}
736-
737-
// Replace missing components with testNow values
738-
$year = $hasYear ? $dateTime->year : $testNow->year;
739-
$month = $hasMonth ? $dateTime->month : $testNow->month;
740-
$day = $hasDay ? $dateTime->day : $testNow->day;
741-
$hour = $hasHour ? $dateTime->hour : $testNow->hour;
742-
$minute = $hasMinute ? $dateTime->minute : $testNow->minute;
743-
$second = $hasSecond ? $dateTime->second : $testNow->second;
744-
$micro = $hasMicro ? $dateTime->micro : $testNow->micro;
745-
746-
// Only modify if something needs to change
747-
if (
748-
!$hasYear || !$hasMonth || !$hasDay ||
749-
!$hasHour || !$hasMinute || !$hasSecond || !$hasMicro
750-
) {
751-
return $dateTime
752-
->setDate($year, $month, $day)
753-
->setTime($hour, $minute, $second, $micro);
754-
}
755-
756707
return $dateTime;
757708
}
758709

759-
/**
760-
* Extract format characters from a format string, handling escapes.
761-
*
762-
* @param string $format The format string.
763-
* @return array<string> Array of format characters.
764-
*/
765-
protected static function getFormatCharacters(string $format): array
766-
{
767-
$chars = [];
768-
$length = strlen($format);
769-
$i = 0;
770-
771-
while ($i < $length) {
772-
$char = $format[$i];
773-
774-
// Backslash escapes the next character
775-
if ($char === '\\' && $i + 1 < $length) {
776-
$i += 2;
777-
continue;
778-
}
779-
780-
$chars[] = $char;
781-
$i++;
782-
}
783-
784-
return $chars;
785-
}
786-
787710
/**
788711
* Returns parse warnings and errors from the last ``createFromFormat()``
789712
* call.

tests/TestCase/DateTime/CreateFromFormatTest.php

Lines changed: 45 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -29,102 +29,47 @@ public function testCreateFromFormatReturnsInstance()
2929
$this->assertTrue($d instanceof Chronos);
3030
}
3131

32-
public function testCreateFromFormatWithTestNowMissingYear()
32+
public function testCreateFromFormatMissingTimeIsZero()
3333
{
34-
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
35-
$d = Chronos::createFromFormat('m-d H:i:s', '10-05 09:15:30');
36-
$this->assertDateTime($d, 2020, 10, 5, 9, 15, 30);
37-
}
38-
39-
public function testCreateFromFormatWithTestNowMissingDate()
40-
{
41-
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
42-
$d = Chronos::createFromFormat('H:i:s', '09:15:30');
43-
$this->assertDateTime($d, 2020, 12, 1, 9, 15, 30);
44-
}
45-
46-
public function testCreateFromFormatWithTestNowMissingTime()
47-
{
48-
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
49-
$d = Chronos::createFromFormat('Y-m-d', '2021-06-15');
50-
$this->assertDateTime($d, 2021, 6, 15, 14, 30, 45);
51-
}
52-
53-
public function testCreateFromFormatWithTestNowPartialDate()
54-
{
55-
Chronos::setTestNow(new Chronos('2020-12-01 00:00:00'));
56-
$d = Chronos::createFromFormat('m-d', '10-05');
57-
$this->assertDateTime($d, 2020, 10, 5, 0, 0, 0);
58-
}
59-
60-
public function testCreateFromFormatWithTestNowDayOnly()
61-
{
62-
Chronos::setTestNow(new Chronos('2020-12-01 00:00:00'));
63-
$d = Chronos::createFromFormat('d', '05');
64-
$this->assertDateTime($d, 2020, 12, 5, 0, 0, 0);
65-
}
66-
67-
public function testCreateFromFormatWithTestNowComplete()
68-
{
69-
// When format is complete, testNow should not affect the result
70-
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
71-
$d = Chronos::createFromFormat('Y-m-d H:i:s', '1975-05-21 22:32:11');
72-
$this->assertDateTime($d, 1975, 5, 21, 22, 32, 11);
73-
}
74-
75-
public function testCreateFromFormatWithTestNowResetModifier()
76-
{
77-
// The '!' modifier resets to Unix epoch, should not use testNow
78-
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
79-
$d = Chronos::createFromFormat('!Y-m-d', '2021-06-15');
80-
$this->assertDateTime($d, 2021, 6, 15, 0, 0, 0);
81-
}
82-
83-
public function testCreateFromFormatWithTestNowPipeModifier()
84-
{
85-
// The '|' modifier resets unspecified components to zero, should not use testNow
86-
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
87-
$d = Chronos::createFromFormat('Y-m-d|', '2021-06-15');
88-
$this->assertDateTime($d, 2021, 6, 15, 0, 0, 0);
34+
// Missing time components should be zero, not current time
35+
$d = Chronos::createFromFormat('Y-m-d', '2024-03-14');
36+
$this->assertDateTime($d, 2024, 3, 14, 0, 0, 0);
37+
$this->assertSame(0, $d->micro);
8938
}
9039

91-
public function testCreateFromFormatWithoutTestNow()
40+
public function testCreateFromFormatMissingSecondsIsZero()
9241
{
93-
// Without testNow set, behavior should use real current time for missing components
94-
Chronos::setTestNow(null);
95-
$d = Chronos::createFromFormat('Y-m-d H:i:s', '1975-05-21 22:32:11');
96-
$this->assertDateTime($d, 1975, 5, 21, 22, 32, 11);
42+
// Missing seconds should be zero
43+
$d = Chronos::createFromFormat('Y-m-d H:i', '2024-03-14 12:30');
44+
$this->assertDateTime($d, 2024, 3, 14, 12, 30, 0);
9745
}
9846

99-
public function testCreateFromFormatWithTestNowEscapedCharacters()
47+
public function testCreateFromFormatMissingDateIsEpoch()
10048
{
101-
// Escaped format characters should not be treated as format specifiers
102-
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
103-
$d = Chronos::createFromFormat('\Y\-m-d', 'Y-10-05');
104-
$this->assertDateTime($d, 2020, 10, 5, 14, 30, 45);
49+
// Missing date components should be Unix epoch (1970-01-01)
50+
$d = Chronos::createFromFormat('H:i:s', '12:30:45');
51+
$this->assertDateTime($d, 1970, 1, 1, 12, 30, 45);
10552
}
10653

107-
public function testCreateFromFormatWithTestNowMicroseconds()
54+
public function testCreateFromFormatMissingMicrosecondsIsZero()
10855
{
109-
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45.123456'));
110-
$d = Chronos::createFromFormat('Y-m-d H:i:s', '2021-06-15 09:15:30');
111-
$this->assertSame(123456, $d->micro);
56+
// Missing microseconds should be zero
57+
$d = Chronos::createFromFormat('Y-m-d H:i:s', '2024-03-14 12:30:45');
58+
$this->assertSame(0, $d->micro);
11259
}
11360

114-
public function testCreateFromFormatWithTestNowUnixTimestamp()
61+
public function testCreateFromFormatWithExplicitPipeModifier()
11562
{
116-
// Unix timestamp ('U' format) sets all components, should not use testNow
117-
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
118-
$d = Chronos::createFromFormat('U', '0');
119-
$this->assertDateTime($d, 1970, 1, 1, 0, 0, 0);
63+
// Explicit | should still work
64+
$d = Chronos::createFromFormat('Y-m-d|', '2024-03-14');
65+
$this->assertDateTime($d, 2024, 3, 14, 0, 0, 0);
12066
}
12167

122-
public function testCreateFromFormatWithTestNowNegativeUnixTimestamp()
68+
public function testCreateFromFormatWithExplicitBangModifier()
12369
{
124-
// Negative Unix timestamp should also not use testNow
125-
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
126-
$d = Chronos::createFromFormat('U', '-1000');
127-
$this->assertDateTime($d, 1969, 12, 31, 23, 43, 20);
70+
// Explicit ! should still work
71+
$d = Chronos::createFromFormat('!Y-m-d', '2024-03-14');
72+
$this->assertDateTime($d, 2024, 3, 14, 0, 0, 0);
12873
}
12974

13075
public function testCreateFromFormatWithTimezoneString()
@@ -147,6 +92,18 @@ public function testCreateFromFormatWithMillis()
14792
$this->assertSame(254687, $d->micro);
14893
}
14994

95+
public function testCreateFromFormatWithUnixTimestamp()
96+
{
97+
$d = Chronos::createFromFormat('U', '0');
98+
$this->assertDateTime($d, 1970, 1, 1, 0, 0, 0);
99+
}
100+
101+
public function testCreateFromFormatWithNegativeUnixTimestamp()
102+
{
103+
$d = Chronos::createFromFormat('U', '-1000');
104+
$this->assertDateTime($d, 1969, 12, 31, 23, 43, 20);
105+
}
106+
150107
public function testCreateFromFormatInvalidFormat()
151108
{
152109
$parseException = null;
@@ -160,4 +117,12 @@ public function testCreateFromFormatInvalidFormat()
160117
$this->assertIsArray(Chronos::getLastErrors());
161118
$this->assertNotEmpty(Chronos::getLastErrors()['errors']);
162119
}
120+
121+
public function testCreateFromFormatDoesNotUseTestNow()
122+
{
123+
// testNow should not affect createFromFormat - missing components are zero
124+
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
125+
$d = Chronos::createFromFormat('Y-m-d', '2024-03-14');
126+
$this->assertDateTime($d, 2024, 3, 14, 0, 0, 0);
127+
}
163128
}

0 commit comments

Comments
 (0)