Repository: briannesbitt/Carbon (php)

Issue:
Carbon period does not accept intervals smaller than 1
<!--
    🛑 DON'T REMOVE ME.
    This issue template applies to all
      - bug reports,
      - feature proposals,
      - and documentation requests

    Having all those informations will allow us to know exactly
    what you expect and answer you faster and precisely (answer
    that matches your Carbon version, PHP version and usage).
    
    Note: Comments between <!- - and - -> won't appear in the final
    issue (See [Preview] tab).
-->
Hello,

I encountered an issue with the following code:
<!--
    ⚠️ Your code below (not the code from inside Carbon library)
    Be sure it's dependency-free and enough to reproduce the issue
    when we run it on:
    https://play.phpsandbox.io/embed/nesbot/carbon
-->
```php
$period = \Carbon\CarbonPeriod::since('2018-04-21 00:00:00')->hours(0.5)->until('2018-04-21 02:00:00');
$timestamps = [];
foreach ($period->toArray() as $date) {
    $timestamps[]= $date->format('Y-m-d H:i:s');
}
var_export($timestamps);
```

Carbon version: **2.66.0**
<!--
    ⚠️ Run the command `composer show nesbot/carbon` to get "versions"
-->

PHP version: **8.2.4**

<!--
    Use `echo phpversion();`
    to get PHP version.

    Some issues can depends on your context, settings,
    macros, timezone, language. So to be sure the code
    you give is enough to reproduce your bug, try it
    first in:
    https://play.phpsandbox.io/embed/nesbot/carbon?input=%3C%3Fphp%0A%0Aecho%20Carbon%3A%3Aparse('2018-06-01')-%3EsubDay()-%3Emonth%3B

    You can use the [Options] button to change the version
    then when you get the bug with this editor, you can use
    the [Export] button, copy the link of the opened tab,
    then paste it in the issue. Then we can immediatly get
    your issue.
-->


I expected to get:

```
array (
  0    '2018-04-21 00:00:00',
  1    '2018-04-21 00:30:00',
  2    '2018-04-21 01:00:00',
  3    '2018-04-21 01:30:00',
  4    '2018-04-21 02:00:00',
)
```
<!--
    Always give your expectations. Each use has their owns.
    You may want daylight saving time to be taken into account,
    someone else want it to be ignored. You may want timezone
    to be used in comparisons, someone else may not, etc.
-->

But I actually get:

```
Carbon\Exceptions\InvalidIntervalException with message 'Empty interval is not accepted.'
```
<!--
    If you did not succeed to get the same result in
    https://play.phpsandbox.io/embed/nesbot/carbon?input=%3C%3Fphp%0A%0Aecho%20Carbon%3A%3Aparse('2018-06-01')%3B then precise the
    result you get.
-->

Thanks!


Pull request description:
Handle decimal part from interval unit setters
Fix #2836

Patch:
diff --git a/src/Carbon/CarbonInterval.php b/src/Carbon/CarbonInterval.php
index 9a3a89b0..2d0cfacb 100644
--- a/src/Carbon/CarbonInterval.php
+++ b/src/Carbon/CarbonInterval.php
@@ -36,6 +36,7 @@ use Exception;
 use InvalidArgumentException;
 use ReflectionException;
 use ReturnTypeWillChange;
+use RuntimeException;
 use Throwable;
 
 /**
@@ -248,6 +249,11 @@ class CarbonInterval extends DateInterval implements CarbonConverterInterface
      */
     private static $flipCascadeFactors;
 
+    /**
+     * @var bool
+     */
+    private static $floatSettersEnabled = false;
+
     /**
      * The registered macros.
      *
@@ -349,6 +355,19 @@ class CarbonInterval extends DateInterval implements CarbonConverterInterface
         static::$cascadeFactors = $cascadeFactors;
     }
 
+    /**
+     * This option allow you to opt-in for the Carbon 3 behavior where float
+     * values will no longer be cast to integer (so truncated).
+     *
+     * ⚠️ This settings will be applied globally, which mean your whole application
+     * code including the third-party dependencies that also may use Carbon will
+     * adopt the new behavior.
+     */
+    public static function enableFloatSetters(bool $floatSettersEnabled = true): void
+    {
+        self::$floatSettersEnabled = $floatSettersEnabled;
+    }
+
     ///////////////////////////////////////////////////////////////////
     //////////////////////////// CONSTRUCTORS /////////////////////////
     ///////////////////////////////////////////////////////////////////
@@ -1225,51 +1244,63 @@ class CarbonInterval extends DateInterval implements CarbonConverterInterface
         foreach ($properties as $key => $value) {
             switch (Carbon::singularUnit(rtrim($key, 'z'))) {
                 case 'year':
-                    $this->assertSafeForInteger($key, $value);
+                    $this->checkIntegerValue($key, $value);
                     $this->y = $value;
+                    $this->handleDecimalPart('year', $value, $this->y);
 
                     break;
 
                 case 'month':
-                    $this->assertSafeForInteger($key, $value);
+                    $this->checkIntegerValue($key, $value);
                     $this->m = $value;
+                    $this->handleDecimalPart('month', $value, $this->m);
 
                     break;
 
                 case 'week':
-                    $this->assertSafeForInteger($key, $value);
-                    $this->d = $value * (int) static::getDaysPerWeek();
+                    $this->checkIntegerValue($key, $value);
+                    $days = $value * (int) static::getDaysPerWeek();
+                    $this->assertSafeForInteger('days total (including weeks)', $days);
+                    $this->d = $days;
+                    $this->handleDecimalPart('day', $days, $this->d);
 
                     break;
 
                 case 'day':
-                    $this->assertSafeForInteger($key, $value);
+                    $this->checkIntegerValue($key, $value);
                     $this->d = $value;
+                    $this->handleDecimalPart('day', $value, $this->d);
 
                     break;
 
                 case 'daysexcludeweek':
                 case 'dayzexcludeweek':
-                    $this->assertSafeForInteger($key, $value);
-                    $this->d = $this->weeks * (int) static::getDaysPerWeek() + $value;
+                    $this->checkIntegerValue($key, $value);
+                    $days = $this->weeks * (int) static::getDaysPerWeek() + $value;
+                    $this->assertSafeForInteger('days total (including weeks)', $days);
+                    $this->d = $days;
+                    $this->handleDecimalPart('day', $days, $this->d);
 
                     break;
 
                 case 'hour':
-                    $this->assertSafeForInteger($key, $value);
+                    $this->checkIntegerValue($key, $value);
                     $this->h = $value;
+                    $this->handleDecimalPart('hour', $value, $this->h);
 
                     break;
 
                 case 'minute':
-                    $this->assertSafeForInteger($key, $value);
+                    $this->checkIntegerValue($key, $value);
                     $this->i = $value;
+                    $this->handleDecimalPart('minute', $value, $this->i);
 
                     break;
 
                 case 'second':
-                    $this->assertSafeForInteger($key, $value);
+                    $this->checkIntegerValue($key, $value);
                     $this->s = $value;
+                    $this->handleDecimalPart('second', $value, $this->s);
 
                     break;
 
@@ -2930,6 +2961,31 @@ class CarbonInterval extends DateInterval implements CarbonConverterInterface
         }
     }
 
+    private function checkIntegerValue(string $name, $value)
+    {
+        if (\is_int($value)) {
+            return;
+        }
+
+        $this->assertSafeForInteger($name, $value);
+
+        if (\is_float($value) && (((float) (int) $value) === $value)) {
+            return;
+        }
+
+        if (!self::$floatSettersEnabled) {
+            $type = \gettype($value);
+            @trigger_error(
+                "Since 2.70.0, it's deprecated to pass $type value for $name.\n".
+                "It's truncated when stored as an integer interval unit.\n".
+                "From 3.0.0, decimal part will no longer be truncated and will be cascaded to smaller units.\n".
+                "- To maintain the current behavior, use explicit cast: $name((int) \$value)\n".
+                "- To adopt the new behavior globally, call CarbonInterval::enableFloatSetters()\n",
+                \E_USER_DEPRECATED
+            );
+        }
+    }
+
     /**
      * Throw an exception if precision loss when storing the given value as an integer would be >= 1.0.
      */
@@ -2939,4 +2995,43 @@ class CarbonInterval extends DateInterval implements CarbonConverterInterface
             throw new OutOfRangeException($name, -0x7fffffffffffffff, 0x7fffffffffffffff, $value);
         }
     }
+
+    private function handleDecimalPart(string $unit, $value, $integerValue)
+    {
+        if (self::$floatSettersEnabled) {
+            $floatValue = (float) $value;
+            $base = (float) $integerValue;
+
+            if ($floatValue === $base) {
+                return;
+            }
+
+            $units = [
+                'y' => 'year',
+                'm' => 'month',
+                'd' => 'day',
+                'h' => 'hour',
+                'i' => 'minute',
+                's' => 'second',
+            ];
+            $upper = true;
+
+            foreach ($units as $property => $name) {
+                if ($name === $unit) {
+                    $upper = false;
+
+                    continue;
+                }
+
+                if (!$upper && $this->$property !== 0) {
+                    throw new RuntimeException(
+                        "You cannot set $unit to a float value as $name would be overridden, ".
+                        'set it first to 0 explicitly if you really want to erase its value'
+                    );
+                }
+            }
+
+            $this->add($unit, $floatValue - $base);
+        }
+    }
 }


Test patch:
diff --git a/tests/CarbonInterval/ConstructTest.php b/tests/CarbonInterval/ConstructTest.php
index ce21ca69..eafa5a6f 100644
--- a/tests/CarbonInterval/ConstructTest.php
+++ b/tests/CarbonInterval/ConstructTest.php
@@ -25,7 +25,9 @@ class ConstructTest extends AbstractTestCase
 {
     public function testInheritedConstruct()
     {
-        CarbonInterval::createFromDateString('1 hour');
+        /** @phpstan-var CarbonInterval $ci */
+        $ci = CarbonInterval::createFromDateString('1 hour');
+        $this->assertSame('PT1H', $ci->spec());
         $ci = new CarbonInterval('PT0S');
         $this->assertSame('PT0S', $ci->spec());
         $ci = new CarbonInterval('P1Y2M3D');
@@ -34,6 +36,8 @@ class ConstructTest extends AbstractTestCase
         $this->assertSame('PT0S', $ci->spec());
         $ci = CarbonInterval::create('P1Y2M3D');
         $this->assertSame('P1Y2M3D', $ci->spec());
+        $ci = CarbonInterval::create('P1Y2M3.0D');
+        $this->assertSame('P1Y2M3D', $ci->spec());
         $ci = CarbonInterval::create('PT9.5H+85M');
         $this->assertSame('PT9H115M', $ci->spec());
         $ci = CarbonInterval::create('PT9H+85M');
diff --git a/tests/CarbonInterval/FloatSettersEnabledTest.php b/tests/CarbonInterval/FloatSettersEnabledTest.php
new file mode 100644
index 00000000..ac24d942
--- /dev/null
+++ b/tests/CarbonInterval/FloatSettersEnabledTest.php
@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * This file is part of the Carbon package.
+ *
+ * (c) Brian Nesbitt <brian@nesbot.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Tests\CarbonInterval;
+
+use Carbon\CarbonInterval;
+use Carbon\CarbonPeriod;
+use RuntimeException;
+use Tests\AbstractTestCase;
+
+class FloatSettersEnabledTest extends AbstractTestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+        CarbonInterval::enableFloatSetters();
+    }
+
+    protected function tearDown(): void
+    {
+        CarbonInterval::enableFloatSetters(false);
+        parent::tearDown();
+    }
+
+    public function testInheritedConstruct()
+    {
+        $ci = new CarbonInterval('PT0S');
+        $ci->hours(0.5);
+        $this->assertSame('PT30M', $ci->spec());
+
+        $ci = new CarbonInterval('P1D');
+        $ci->hours(0.5);
+        $this->assertSame('P1DT30M', $ci->spec());
+
+        $ci = new CarbonInterval('PT4H');
+        $ci->hours(0.5);
+        $this->assertSame('PT30M', $ci->spec());
+
+        $period = CarbonPeriod::since('2018-04-21 00:00:00')->hours(0.5)->until('2018-04-21 02:00:00');
+        $this->assertSame('2018-04-21 00:30:00', $period->toArray()[1]->format('Y-m-d H:i:s'));
+
+        CarbonInterval::enableFloatSetters(false);
+        $ci = new CarbonInterval('PT4H');
+        $ci->hours(0.5);
+        $this->assertSame('PT0S', $ci->spec());
+    }
+
+    public function testOverridePrevention()
+    {
+        $this->expectExceptionObject(new RuntimeException(
+            'You cannot set hour to a float value as minute would be overridden, '.
+            'set it first to 0 explicitly if you really want to erase its value'
+        ));
+
+        $ci = new CarbonInterval('PT10M');
+        $ci->hours(0.5);
+    }
+}