diff --git a/phpstan.neon b/phpstan.neon index 4d4a218f26..2f94db991a 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -52,3 +52,4 @@ parameters: - '*/tests/CarbonPeriod/Fixtures/filters.php' - '*/tests/Fixtures/dynamicInterval.php' - '*/tests/PHPStan/*.php' + - '*/tests/PHPUnit/AssertObjectHasPropertyPolyfillTrait.php' diff --git a/src/Carbon/CarbonInterval.php b/src/Carbon/CarbonInterval.php index 9eb49c028f..9a3a89b0b6 100644 --- a/src/Carbon/CarbonInterval.php +++ b/src/Carbon/CarbonInterval.php @@ -15,6 +15,7 @@ use Carbon\Exceptions\BadFluentSetterException; use Carbon\Exceptions\InvalidCastException; use Carbon\Exceptions\InvalidIntervalException; +use Carbon\Exceptions\OutOfRangeException; use Carbon\Exceptions\ParseErrorException; use Carbon\Exceptions\UnitNotConfiguredException; use Carbon\Exceptions\UnknownGetterException; @@ -32,6 +33,7 @@ use DateTimeInterface; use DateTimeZone; use Exception; +use InvalidArgumentException; use ReflectionException; use ReturnTypeWillChange; use Throwable; @@ -355,13 +357,13 @@ public static function setCascadeFactors(array $cascadeFactors) * Create a new CarbonInterval instance. * * @param Closure|DateInterval|string|int|null $years - * @param int|null $months - * @param int|null $weeks - * @param int|null $days - * @param int|null $hours - * @param int|null $minutes - * @param int|null $seconds - * @param int|null $microseconds + * @param int|float|null $months + * @param int|float|null $weeks + * @param int|float|null $days + * @param int|float|null $hours + * @param int|float|null $minutes + * @param int|float|null $seconds + * @param int|float|null $microseconds * * @throws Exception when the interval_spec (passed as $years) cannot be parsed as an interval. */ @@ -381,8 +383,9 @@ public function __construct($years = 1, $months = null, $weeks = null, $days = n } $spec = $years; + $isStringSpec = (\is_string($spec) && !preg_match('/^[\d.]/', $spec)); - if (!\is_string($spec) || (float) $years || preg_match('/^[\d.]/', $years)) { + if (!$isStringSpec || (float) $years) { $spec = static::PERIOD_PREFIX; $spec .= $years > 0 ? $years.static::PERIOD_YEARS : ''; @@ -407,7 +410,74 @@ public function __construct($years = 1, $months = null, $weeks = null, $days = n } } - parent::__construct($spec); + try { + parent::__construct($spec); + } catch (Throwable $exception) { + try { + parent::__construct('PT0S'); + + if ($isStringSpec) { + if (!preg_match('/^P + (?:(?[+-]?\d*(?:\.\d+)?)Y)? + (?:(?[+-]?\d*(?:\.\d+)?)M)? + (?:(?[+-]?\d*(?:\.\d+)?)W)? + (?:(?[+-]?\d*(?:\.\d+)?)D)? + (?:T + (?:(?[+-]?\d*(?:\.\d+)?)H)? + (?:(?[+-]?\d*(?:\.\d+)?)M)? + (?:(?[+-]?\d*(?:\.\d+)?)S)? + )? + $/x', $spec, $match)) { + throw new InvalidArgumentException("Invalid duration: $spec"); + } + + $years = (float) ($match['year'] ?? 0); + $this->assertSafeForInteger('year', $years); + $months = (float) ($match['month'] ?? 0); + $this->assertSafeForInteger('month', $months); + $weeks = (float) ($match['week'] ?? 0); + $this->assertSafeForInteger('week', $weeks); + $days = (float) ($match['day'] ?? 0); + $this->assertSafeForInteger('day', $days); + $hours = (float) ($match['hour'] ?? 0); + $this->assertSafeForInteger('hour', $hours); + $minutes = (float) ($match['minute'] ?? 0); + $this->assertSafeForInteger('minute', $minutes); + $seconds = (float) ($match['second'] ?? 0); + $this->assertSafeForInteger('second', $seconds); + } + + $totalDays = (($weeks * static::getDaysPerWeek()) + $days); + $this->assertSafeForInteger('days total (including weeks)', $totalDays); + + $this->y = (int) $years; + $this->m = (int) $months; + $this->d = (int) $totalDays; + $this->h = (int) $hours; + $this->i = (int) $minutes; + $this->s = (int) $seconds; + + if ( + ((float) $this->y) !== $years || + ((float) $this->m) !== $months || + ((float) $this->d) !== $totalDays || + ((float) $this->h) !== $hours || + ((float) $this->i) !== $minutes || + ((float) $this->s) !== $seconds + ) { + $this->add(static::fromString( + ($years - $this->y).' years '. + ($months - $this->m).' months '. + ($totalDays - $this->d).' days '. + ($hours - $this->h).' hours '. + ($minutes - $this->i).' minutes '. + ($seconds - $this->s).' seconds ' + )); + } + } catch (Throwable $secondException) { + throw $secondException instanceof OutOfRangeException ? $secondException : $exception; + } + } if ($microseconds !== null) { $this->f = $microseconds / Carbon::MICROSECONDS_PER_SECOND; @@ -953,11 +1023,18 @@ public function cast(string $className) * set the $days field. * * @param DateInterval $interval + * @param bool $skipCopy set to true to return the passed object + * (without copying it) if it's already of the + * current class * * @return static */ - public static function instance(DateInterval $interval, array $skip = []) + public static function instance(DateInterval $interval, array $skip = [], bool $skipCopy = false) { + if ($skipCopy && $interval instanceof static) { + return $interval; + } + return self::castIntervalToClass($interval, static::class, $skip); } @@ -969,17 +1046,20 @@ public static function instance(DateInterval $interval, array $skip = []) * * @param mixed|int|DateInterval|string|Closure|null $interval interval or number of the given $unit * @param string|null $unit if specified, $interval must be an integer + * @param bool $skipCopy set to true to return the passed object + * (without copying it) if it's already of the + * current class * * @return static|null */ - public static function make($interval, $unit = null) + public static function make($interval, $unit = null, bool $skipCopy = false) { if ($unit) { $interval = "$interval ".Carbon::pluralUnit($unit); } if ($interval instanceof DateInterval) { - return static::instance($interval); + return static::instance($interval, [], $skipCopy); } if ($interval instanceof Closure) { @@ -1145,42 +1225,50 @@ public function set($name, $value = null) foreach ($properties as $key => $value) { switch (Carbon::singularUnit(rtrim($key, 'z'))) { case 'year': + $this->assertSafeForInteger($key, $value); $this->y = $value; break; case 'month': + $this->assertSafeForInteger($key, $value); $this->m = $value; break; case 'week': + $this->assertSafeForInteger($key, $value); $this->d = $value * (int) static::getDaysPerWeek(); break; case 'day': + $this->assertSafeForInteger($key, $value); $this->d = $value; break; case 'daysexcludeweek': case 'dayzexcludeweek': + $this->assertSafeForInteger($key, $value); $this->d = $this->weeks * (int) static::getDaysPerWeek() + $value; break; case 'hour': + $this->assertSafeForInteger($key, $value); $this->h = $value; break; case 'minute': + $this->assertSafeForInteger($key, $value); $this->i = $value; break; case 'second': + $this->assertSafeForInteger($key, $value); $this->s = $value; break; @@ -2841,4 +2929,14 @@ private function needsDeclension(string $mode, int $index, int $parts): bool return true; } } + + /** + * Throw an exception if precision loss when storing the given value as an integer would be >= 1.0. + */ + private function assertSafeForInteger(string $name, $value) + { + if ($value && !\is_int($value) && ($value >= 0x7fffffffffffffff || $value <= -0x7fffffffffffffff)) { + throw new OutOfRangeException($name, -0x7fffffffffffffff, 0x7fffffffffffffff, $value); + } + } } diff --git a/src/Carbon/Traits/IntervalRounding.php b/src/Carbon/Traits/IntervalRounding.php index 4cd66b676f..f069c280d7 100644 --- a/src/Carbon/Traits/IntervalRounding.php +++ b/src/Carbon/Traits/IntervalRounding.php @@ -40,7 +40,7 @@ protected function roundWith($precision, $function) $unit = 'second'; if ($precision instanceof DateInterval) { - $precision = (string) CarbonInterval::instance($precision); + $precision = (string) CarbonInterval::instance($precision, [], true); } if (\is_string($precision) && preg_match('/^\s*(?\d+)?\s*(?\w+)(?\W.*)?$/', $precision, $match)) { diff --git a/src/Carbon/Traits/Units.php b/src/Carbon/Traits/Units.php index 7aadd65bde..5be14ec7ef 100644 --- a/src/Carbon/Traits/Units.php +++ b/src/Carbon/Traits/Units.php @@ -198,7 +198,7 @@ public function rawAdd(DateInterval $interval) public function add($unit, $value = 1, $overflow = null) { if (\is_string($unit) && \func_num_args() === 1) { - $unit = CarbonInterval::make($unit); + $unit = CarbonInterval::make($unit, [], true); } if ($unit instanceof CarbonConverterInterface) { @@ -368,7 +368,7 @@ public function rawSub(DateInterval $interval) public function sub($unit, $value = 1, $overflow = null) { if (\is_string($unit) && \func_num_args() === 1) { - $unit = CarbonInterval::make($unit); + $unit = CarbonInterval::make($unit, [], true); } if ($unit instanceof CarbonConverterInterface) { @@ -404,7 +404,7 @@ public function sub($unit, $value = 1, $overflow = null) public function subtract($unit, $value = 1, $overflow = null) { if (\is_string($unit) && \func_num_args() === 1) { - $unit = CarbonInterval::make($unit); + $unit = CarbonInterval::make($unit, [], true); } return $this->sub($unit, $value, $overflow); diff --git a/tests/AbstractTestCase.php b/tests/AbstractTestCase.php index 98a95a89df..60b87d7b51 100644 --- a/tests/AbstractTestCase.php +++ b/tests/AbstractTestCase.php @@ -23,6 +23,7 @@ use DateTime; use PHPUnit\Framework\TestCase; use ReflectionProperty; +use Tests\PHPUnit\AssertObjectHasPropertyTrait; use Throwable; /** @@ -30,6 +31,8 @@ */ abstract class AbstractTestCase extends TestCase { + use AssertObjectHasPropertyTrait; + /** * @var \Carbon\Carbon */ diff --git a/tests/Carbon/ObjectsTest.php b/tests/Carbon/ObjectsTest.php index e5a4d5f768..2c211bed98 100644 --- a/tests/Carbon/ObjectsTest.php +++ b/tests/Carbon/ObjectsTest.php @@ -28,40 +28,40 @@ public function testToObject() $this->assertInstanceOf(stdClass::class, $dtToObject); - $this->assertObjectHasAttribute('year', $dtToObject); + $this->assertObjectHasProperty('year', $dtToObject); $this->assertSame($dt->year, $dtToObject->year); - $this->assertObjectHasAttribute('month', $dtToObject); + $this->assertObjectHasProperty('month', $dtToObject); $this->assertSame($dt->month, $dtToObject->month); - $this->assertObjectHasAttribute('day', $dtToObject); + $this->assertObjectHasProperty('day', $dtToObject); $this->assertSame($dt->day, $dtToObject->day); - $this->assertObjectHasAttribute('dayOfWeek', $dtToObject); + $this->assertObjectHasProperty('dayOfWeek', $dtToObject); $this->assertSame($dt->dayOfWeek, $dtToObject->dayOfWeek); - $this->assertObjectHasAttribute('dayOfYear', $dtToObject); + $this->assertObjectHasProperty('dayOfYear', $dtToObject); $this->assertSame($dt->dayOfYear, $dtToObject->dayOfYear); - $this->assertObjectHasAttribute('hour', $dtToObject); + $this->assertObjectHasProperty('hour', $dtToObject); $this->assertSame($dt->hour, $dtToObject->hour); - $this->assertObjectHasAttribute('minute', $dtToObject); + $this->assertObjectHasProperty('minute', $dtToObject); $this->assertSame($dt->minute, $dtToObject->minute); - $this->assertObjectHasAttribute('second', $dtToObject); + $this->assertObjectHasProperty('second', $dtToObject); $this->assertSame($dt->second, $dtToObject->second); - $this->assertObjectHasAttribute('micro', $dtToObject); + $this->assertObjectHasProperty('micro', $dtToObject); $this->assertSame($dt->micro, $dtToObject->micro); - $this->assertObjectHasAttribute('timestamp', $dtToObject); + $this->assertObjectHasProperty('timestamp', $dtToObject); $this->assertSame($dt->timestamp, $dtToObject->timestamp); - $this->assertObjectHasAttribute('timezone', $dtToObject); + $this->assertObjectHasProperty('timezone', $dtToObject); $this->assertEquals($dt->timezone, $dtToObject->timezone); - $this->assertObjectHasAttribute('formatted', $dtToObject); + $this->assertObjectHasProperty('formatted', $dtToObject); $this->assertSame($dt->format(Carbon::DEFAULT_TO_STRING_FORMAT), $dtToObject->formatted); } diff --git a/tests/CarbonImmutable/ObjectsTest.php b/tests/CarbonImmutable/ObjectsTest.php index 3ae51523bb..f5f5dad8a0 100644 --- a/tests/CarbonImmutable/ObjectsTest.php +++ b/tests/CarbonImmutable/ObjectsTest.php @@ -28,40 +28,40 @@ public function testToObject() $this->assertInstanceOf(stdClass::class, $dtToObject); - $this->assertObjectHasAttribute('year', $dtToObject); + $this->assertObjectHasProperty('year', $dtToObject); $this->assertSame($dt->year, $dtToObject->year); - $this->assertObjectHasAttribute('month', $dtToObject); + $this->assertObjectHasProperty('month', $dtToObject); $this->assertSame($dt->month, $dtToObject->month); - $this->assertObjectHasAttribute('day', $dtToObject); + $this->assertObjectHasProperty('day', $dtToObject); $this->assertSame($dt->day, $dtToObject->day); - $this->assertObjectHasAttribute('dayOfWeek', $dtToObject); + $this->assertObjectHasProperty('dayOfWeek', $dtToObject); $this->assertSame($dt->dayOfWeek, $dtToObject->dayOfWeek); - $this->assertObjectHasAttribute('dayOfYear', $dtToObject); + $this->assertObjectHasProperty('dayOfYear', $dtToObject); $this->assertSame($dt->dayOfYear, $dtToObject->dayOfYear); - $this->assertObjectHasAttribute('hour', $dtToObject); + $this->assertObjectHasProperty('hour', $dtToObject); $this->assertSame($dt->hour, $dtToObject->hour); - $this->assertObjectHasAttribute('minute', $dtToObject); + $this->assertObjectHasProperty('minute', $dtToObject); $this->assertSame($dt->minute, $dtToObject->minute); - $this->assertObjectHasAttribute('second', $dtToObject); + $this->assertObjectHasProperty('second', $dtToObject); $this->assertSame($dt->second, $dtToObject->second); - $this->assertObjectHasAttribute('micro', $dtToObject); + $this->assertObjectHasProperty('micro', $dtToObject); $this->assertSame($dt->micro, $dtToObject->micro); - $this->assertObjectHasAttribute('timestamp', $dtToObject); + $this->assertObjectHasProperty('timestamp', $dtToObject); $this->assertSame($dt->timestamp, $dtToObject->timestamp); - $this->assertObjectHasAttribute('timezone', $dtToObject); + $this->assertObjectHasProperty('timezone', $dtToObject); $this->assertEquals($dt->timezone, $dtToObject->timezone); - $this->assertObjectHasAttribute('formatted', $dtToObject); + $this->assertObjectHasProperty('formatted', $dtToObject); $this->assertSame($dt->format(Carbon::DEFAULT_TO_STRING_FORMAT), $dtToObject->formatted); } diff --git a/tests/CarbonInterval/ConstructTest.php b/tests/CarbonInterval/ConstructTest.php index 30a9c5064d..a69159cc05 100644 --- a/tests/CarbonInterval/ConstructTest.php +++ b/tests/CarbonInterval/ConstructTest.php @@ -16,13 +16,16 @@ use BadMethodCallException; use Carbon\Carbon; use Carbon\CarbonInterval; +use Carbon\Exceptions\OutOfRangeException; use DateInterval; +use Exception; use Tests\AbstractTestCase; class ConstructTest extends AbstractTestCase { public function testInheritedConstruct() { + CarbonInterval::createFromDateString('1 hour'); $ci = new CarbonInterval('PT0S'); $this->assertSame('PT0S', $ci->spec()); $ci = new CarbonInterval('P1Y2M3D'); @@ -31,6 +34,10 @@ public function testInheritedConstruct() $this->assertSame('PT0S', $ci->spec()); $ci = CarbonInterval::create('P1Y2M3D'); $this->assertSame('P1Y2M3D', $ci->spec()); + $ci = CarbonInterval::create('PT9.5H+85M'); + $this->assertSame('PT9H115M', $ci->spec()); + $ci = CarbonInterval::create('PT1999999999999.5H+85M'); + $this->assertSame('PT1999999999999H115M', $ci->spec()); } public function testConstructWithDateInterval() @@ -315,6 +322,25 @@ public function testMake() $this->assertSame(3000, CarbonInterval::make('3 millennia')->totalYears); } + public function testBadFormats() + { + $this->expectExceptionObject(new Exception('PT1999999999999.5.5H+85M')); + + CarbonInterval::create('PT1999999999999.5.5H+85M'); + } + + public function testOutOfRange() + { + $this->expectExceptionObject(new OutOfRangeException( + 'hour', + -0x7fffffffffffffff, + 0x7fffffffffffffff, + 999999999999999999999999 + )); + + CarbonInterval::create('PT999999999999999999999999H'); + } + public function testCallInvalidStaticMethod() { $this->expectExceptionObject(new BadMethodCallException( diff --git a/tests/CarbonInterval/FromStringTest.php b/tests/CarbonInterval/FromStringTest.php index 794877592a..5ddd505a80 100644 --- a/tests/CarbonInterval/FromStringTest.php +++ b/tests/CarbonInterval/FromStringTest.php @@ -105,6 +105,11 @@ public static function dataForValidStrings(): Generator // case insensitive yield ['1Y 3MO 1W 3D 12H 23M 42S', new CarbonInterval(1, 3, 1, 3, 12, 23, 42)]; + + // big numbers + yield ['1999999999999.5 hours', new CarbonInterval(0, 0, 0, 0, 1999999999999, 30, 0)]; + yield [(0x7fffffffffffffff).' days', new CarbonInterval(0, 0, 0, 0x7fffffffffffffff, 0, 0, 0)]; + yield ['1999999999999.5 hours -85 minutes', new CarbonInterval(0, 0, 0, 0, 1999999999999, 115, 0)]; } /** diff --git a/tests/PHPUnit/AssertObjectHasPropertyNoopTrait.php b/tests/PHPUnit/AssertObjectHasPropertyNoopTrait.php new file mode 100644 index 0000000000..b500d5cf57 --- /dev/null +++ b/tests/PHPUnit/AssertObjectHasPropertyNoopTrait.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\PHPUnit; + +trait AssertObjectHasPropertyTrait +{ +} diff --git a/tests/PHPUnit/AssertObjectHasPropertyPolyfillTrait.php b/tests/PHPUnit/AssertObjectHasPropertyPolyfillTrait.php new file mode 100644 index 0000000000..844db5de60 --- /dev/null +++ b/tests/PHPUnit/AssertObjectHasPropertyPolyfillTrait.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\PHPUnit; + +trait AssertObjectHasPropertyTrait +{ + protected static function assertObjectHasProperty(string $attributeName, $object, string $message = '') + { + self::assertObjectHasAttribute($attributeName, $object, $message); + } +} diff --git a/tests/PHPUnit/AssertObjectHasPropertyTrait.php b/tests/PHPUnit/AssertObjectHasPropertyTrait.php new file mode 100644 index 0000000000..b565eb0f30 --- /dev/null +++ b/tests/PHPUnit/AssertObjectHasPropertyTrait.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\PHPUnit; + +use PHPUnit\Framework\TestCase; + +require_once method_exists(TestCase::class, 'assertObjectHasProperty') + ? __DIR__.'/AssertObjectHasPropertyNoopTrait.php' + : __DIR__.'/AssertObjectHasPropertyPolyfillTrait.php';