diff --git a/src/Carbon/CarbonInterval.php b/src/Carbon/CarbonInterval.php index 9eb49c028f..53b3022b1c 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; @@ -355,13 +356,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 +382,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 +409,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 $_) { + throw $exception; + } + } if ($microseconds !== null) { $this->f = $microseconds / Carbon::MICROSECONDS_PER_SECOND; @@ -953,11 +1022,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 +1045,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 +1224,50 @@ public function set($name, $value = null) foreach ($properties as $key => $value) { switch (Carbon::singularUnit(rtrim($key, 'z'))) { case 'year': + $this->checkIntegerValue($name, $value); $this->y = $value; break; case 'month': + $this->checkIntegerValue($name, $value); $this->m = $value; break; case 'week': + $this->checkIntegerValue($name, $value); $this->d = $value * (int) static::getDaysPerWeek(); break; case 'day': + $this->checkIntegerValue($name, $value); $this->d = $value; break; case 'daysexcludeweek': case 'dayzexcludeweek': + $this->checkIntegerValue($name, $value); $this->d = $this->weeks * (int) static::getDaysPerWeek() + $value; break; case 'hour': + $this->checkIntegerValue($name, $value); $this->h = $value; break; case 'minute': + $this->checkIntegerValue($name, $value); $this->i = $value; break; case 'second': + $this->checkIntegerValue($name, $value); $this->s = $value; break; @@ -2841,4 +2928,23 @@ private function needsDeclension(string $mode, int $index, int $parts): bool return true; } } + + private function checkIntegerValue(string $name, $value) + { + if (\is_int($value)) { + return; + } + + $this->assertSafeForInteger($name, $value); + } + + /** + * 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 && ($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/CarbonInterval/ConstructTest.php b/tests/CarbonInterval/ConstructTest.php index 30a9c5064d..5ba51acad9 100644 --- a/tests/CarbonInterval/ConstructTest.php +++ b/tests/CarbonInterval/ConstructTest.php @@ -17,12 +17,14 @@ use Carbon\Carbon; use Carbon\CarbonInterval; 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 +33,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 +321,33 @@ public function testMake() $this->assertSame(3000, CarbonInterval::make('3 millennia')->totalYears); } + /** + * @dataProvider getBadFormats + */ + public function testBadFormats(string $format) + { + $this->expectExceptionObject(new Exception( + 'Unknown or bad format (PT999999999999999999999999H)' + )); + + CarbonInterval::create('PT999999999999999999999999H'); + } + + public function getBadFormats() + { + $badFormats = [ + 'PT1999999999999.5.5H+85M', + 'PT999999999999999999999999H', + ]; + + return array_combine($badFormats, array_map( + static function (string $format) { + return [$format]; + }, + $badFormats, + )); + } + 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)]; } /**