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
39 changes: 38 additions & 1 deletion src/Internal/Support/DateInterval.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,21 @@ public static function parse(mixed $interval, string $format = self::FORMAT_MILL
{
switch (true) {
case \is_string($interval):
return CarbonInterval::fromString($interval);
$carbon = CarbonInterval::fromString($interval);
if (self::isIso8601DurationFormat($interval)) {
$builtin = new \DateInterval($interval);
$carbon->compare($builtin) === 0 or \trigger_error(
\sprintf(
'Ambiguous duration "%s": Carbon and DateInterval parse it differently. ' .
'Use new \DateInterval("%s") for ISO 8601 standard parsing or PT/P prefix to clarify intent.',
$interval,
$interval,
),
\E_USER_WARNING,
);
}

return $carbon;

case $interval instanceof \DateInterval:
return CarbonInterval::instance($interval);
Expand Down Expand Up @@ -180,4 +194,27 @@ private static function validateFormat(string $format): void
throw new \InvalidArgumentException($message);
}
}

/**
* Checks if a string matches the ISO 8601 duration format that PHP's DateInterval constructor accepts.
*
* Valid format: P[n]Y[n]M[n]W[n]D[T[n]H[n]M[n]S]
* - Must start with P (period)
* - Date elements (Y, M, W, D) come before T
* - Time elements (H, M, S) come after T
* - At least one date or time element must be present
* - Alternative datetime format P<date>T<time> is also supported
*
* Examples: P2D, PT5M, P1Y2M3DT4H5M6S, P0001-00-00T00:00:00
*/
private static function isIso8601DurationFormat(string $interval): bool
{
// ISO 8601 duration format: P[n]Y[n]M[n]W[n]D[T[n]H[n]M[n]S]
// At least one element (Y, M, W, D, H, M, or S) must be present
// Alternative format: P<date>T<time> like P0001-00-00T00:00:00
return \preg_match(
'/^P(?=.)(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?=.)(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$|^P\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/',
$interval,
) === 1 && $interval !== 'P' && $interval !== 'PT';
}
}
168 changes: 168 additions & 0 deletions tests/Unit/Internal/Support/DateIntervalTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,35 @@ public static function provideValuesToParse(): iterable
yield [null, DateInterval::FORMAT_MILLISECONDS, 0, '0/0/0/0'];
}

public static function provideIso8601DurationFormats(): \Generator
{
// Valid ISO 8601 duration formats
yield 'two days' => ['P2D', true];
yield 'two seconds' => ['PT2S', true];
yield 'six years five minutes' => ['P6YT5M', true];
yield 'three months' => ['P3M', true];
yield 'three minutes' => ['PT3M', true];
yield 'full format' => ['P1Y2M3DT4H5M6S', true];
yield 'weeks only' => ['P2W', true];
yield 'hours and minutes' => ['PT1H30M', true];
yield 'days and hours' => ['P1DT12H', true];
yield 'alternative datetime format' => ['P0001-00-00T00:00:00', true];
yield 'decimal seconds' => ['PT1.5S', true];

// Invalid formats (Carbon-specific or non-ISO 8601)
yield 'only period marker' => ['P', false];
yield 'only period and time marker' => ['PT', false];
yield 'natural language' => ['2 days', false];
yield 'human readable' => ['1 hour 30 minutes', false];
yield 'no period marker' => ['2D', false];
yield 'wrong order' => ['P4D1Y', false];
yield 'time without T' => ['P1H30M', false];
yield 'negative value' => ['P-2D', false];
yield 'negative prefix' => ['-P2D', false];
yield 'spaces' => ['P 2 D', false];
yield 'lowercase' => ['p2d', false];
}

#[DataProvider('provideValuesToParse')]
public function testParse(mixed $value, string $format, int $microseconds, string $formatted): void
{
Expand Down Expand Up @@ -62,4 +91,143 @@ public function testParseFromDuration(): void
self::assertSame(5124, (int) $i->totalSeconds);
self::assertSame(123_456, $i->microseconds);
}

#[DataProvider('provideIso8601DurationFormats')]
public function testParseDetectsIso8601FormatCorrectly(string $interval, bool $shouldBeIso8601): void
{
// Arrange
$reflection = new \ReflectionClass(DateInterval::class);
$method = $reflection->getMethod('isIso8601DurationFormat');
$method->setAccessible(true);

// Act
$result = $method->invoke(null, $interval);

// Assert
self::assertSame(
$shouldBeIso8601,
$result,
\sprintf(
'String "%s" should %s recognized as ISO 8601 duration format',
$interval,
$shouldBeIso8601 ? 'be' : 'NOT be',
),
);
}

public static function provideCarbonDateIntervalDifferences(): \Generator
{
// Cases where Carbon and DateInterval parse the same string differently
// Format: [interval string, expected warning]

// P2M: Carbon parses as 2 minutes, DateInterval as 2 months
yield 'P2M - ambiguous months/minutes' => ['P2M', true];

// Cases that should NOT trigger warning (identical parsing)
yield 'PT2M - explicit minutes with T' => ['PT2M', false];
yield 'P1Y - explicit years' => ['P1Y', false];
yield 'P2D - explicit days' => ['P2D', false];
yield 'PT5S - explicit seconds' => ['PT5S', false];
}

#[DataProvider('provideCarbonDateIntervalDifferences')]
public function testParseTriggersWarningWhenCarbonAndDateIntervalDiffer(
string $interval,
bool $shouldTriggerWarning,
): void {
// Arrange
$warningTriggered = false;
$warningMessage = '';

\set_error_handler(static function (int $errno, string $errstr) use (&$warningTriggered, &$warningMessage): bool {
if ($errno === \E_USER_WARNING && \str_contains($errstr, 'Ambiguous duration')) {
$warningTriggered = true;
$warningMessage = $errstr;
return true;
}
return false;
});

// Act
try {
$result = DateInterval::parse($interval);
} finally {
\restore_error_handler();
}

// Assert
self::assertInstanceOf(\Carbon\CarbonInterval::class, $result);

if ($shouldTriggerWarning) {
self::assertTrue(
$warningTriggered,
\sprintf('Expected warning for interval "%s" but none was triggered', $interval),
);
self::assertStringContainsString(
'Ambiguous duration',
$warningMessage,
'Warning message should mention ambiguous duration',
);
self::assertStringContainsString(
\sprintf('"%s"', $interval),
$warningMessage,
'Warning message should contain the interval value',
);
self::assertStringContainsString(
'Carbon and DateInterval parse it differently',
$warningMessage,
'Warning message should explain the issue',
);
} else {
self::assertFalse(
$warningTriggered,
\sprintf(
'Did not expect warning for interval "%s" but one was triggered: %s',
$interval,
$warningMessage,
),
);
}
}

public static function provideNonIso8601FormatsNoWarning(): \Generator
{
// Natural language formats that Carbon accepts but aren't ISO 8601
// These should NOT trigger warnings because they don't match ISO 8601 format
yield 'natural language - 2 days' => ['2 days'];
yield 'natural language - 1 hour' => ['1 hour'];
yield 'natural language - 30 minutes' => ['30 minutes'];
}

#[DataProvider('provideNonIso8601FormatsNoWarning')]
public function testParseDoesNotTriggerWarningForNonIso8601Formats(string $interval): void
{
// Arrange
$warningTriggered = false;

\set_error_handler(static function (int $errno, string $errstr) use (&$warningTriggered): bool {
if ($errno === \E_USER_WARNING && \str_contains($errstr, 'Ambiguous duration')) {
$warningTriggered = true;
return true;
}
return false;
});

// Act
try {
$result = DateInterval::parse($interval);
} finally {
\restore_error_handler();
}

// Assert
self::assertInstanceOf(\Carbon\CarbonInterval::class, $result);
self::assertFalse(
$warningTriggered,
\sprintf(
'Non-ISO 8601 format "%s" should not trigger DateInterval comparison warning',
$interval,
),
);
}
}
Loading