From 1549b9a0d2df27f0f6ae93cb364cb528b64c483c Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 19:01:13 +0200 Subject: [PATCH] Tidy up conversion and creation for 2.0 toInt() dropped decimals without notice, the last call that did so. It now refuses a value with decimals other than zero unless a rounding mode is passed; ROUND_DOWN gives the old result. The range check runs on the rounded value, so a value that rounds past the integer range overflows. isBigInteger() is replaced by fitsInt(). The old name read as "is a large whole number" while it only looked at the range, and callers always negated it before toInt(). The value type is Stringable instead of object. Other objects were only turned away after the call; every class with __toString() is Stringable. create() returns a Decimal as is when no scale is passed. The object is immutable, so the copy served no purpose. truncate() goes through round() with ROUND_DOWN, with the same results as before, including that it never adds zeros. --- docs/guide/conversion.md | 10 +++-- docs/guide/scale.md | 2 +- docs/reference/api.md | 8 ++-- docs/reference/upgrading.md | 15 ++++++++ src/Decimal.php | 59 ++++++++++++++++-------------- tests/DecimalTest.php | 73 ++++++++++++++++++++++++++++++++++--- 6 files changed, 125 insertions(+), 42 deletions(-) diff --git a/docs/guide/conversion.md b/docs/guide/conversion.md index c5b634e..e673d0b 100644 --- a/docs/guide/conversion.md +++ b/docs/guide/conversion.md @@ -31,18 +31,20 @@ A JSON number would be read back as a float by most clients, which defeats the p ## Integer -`toInt()` drops the decimals without rounding: +`toInt()` returns whole numbers as they are. A value with decimals needs a rounding mode, otherwise a `RoundingNecessaryException` is thrown: ```php -echo Decimal::create('-23.74')->toInt(); // -23 +echo Decimal::create('23.00')->toInt(); // 23 +echo Decimal::create('-23.74')->toInt(Decimal::ROUND_DOWN); // -23, same as an int cast +echo Decimal::create('-23.74')->toInt(Decimal::ROUND_HALF_UP); // -24 ``` -Values outside the range of a PHP integer throw an `OverflowException`. Check with `isBigInteger()` first when the value may be large: +Values outside the range of a PHP integer throw an `OverflowException`. Check with `fitsInt()` first when the value may be large: ```php $value = Decimal::create('9223372036854775808'); -if (!$value->isBigInteger()) { +if ($value->fitsInt()) { $int = $value->toInt(); } ``` diff --git a/docs/guide/scale.md b/docs/guide/scale.md index 34bbcb8..0e5a943 100644 --- a/docs/guide/scale.md +++ b/docs/guide/scale.md @@ -87,4 +87,4 @@ echo Decimal::create('99999999999999999999.99')->add('0.01'); // 100000000000000000000.00 ``` -`isBigInteger()` tells you whether a value still fits a PHP integer. See [Conversion](./conversion). +`fitsInt()` tells you whether a value still fits a PHP integer. See [Conversion](./conversion). diff --git a/docs/reference/api.md b/docs/reference/api.md index 0659c5f..8447624 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -11,7 +11,7 @@ Class `PhpCollective\DecimalObject\Decimal`, implementing `JsonSerializable` and | Method | Description | | --- | --- | | `new Decimal($value, ?int $scale = null)` | Creates a decimal. Also accepts any `Stringable` with a numeric string. | -| `Decimal::create($value, ?int $scale = null): static` | Same as the constructor, for chaining. | +| `Decimal::create($value, ?int $scale = null): static` | Same as the constructor, for chaining. A `Decimal` passed without `$scale` is returned as is. | | `Decimal::tryCreate($value, ?int $scale = null): ?static` | Returns `null` for `null` and for input that is not numeric. | | `Decimal::createFromFloat(float $value): static` | The only way to pass a float. Keeps all its digits. | | `Decimal::zero(): static`, `Decimal::one(): static` | The values `0` and `1`. | @@ -59,7 +59,7 @@ See [Arithmetic](../guide/arithmetic). | `roundToStep($step, $roundMode = ROUND_HALF_UP): static` | Rounds to the nearest multiple of `$step`. | | `floor(): static` | Nearest integer towards negative infinity. | | `ceil(): static` | Nearest integer towards positive infinity. | -| `truncate(int $scale = 0): static` | Cuts off digits beyond `$scale`. | +| `truncate(int $scale = 0): static` | Cuts off digits beyond `$scale`, like `round()` with `ROUND_DOWN`, but never adds zeros. | | `trim(): static` | Removes trailing zeros after the decimal point. | | Constant | Value | Native `RoundingMode` | @@ -103,7 +103,7 @@ See [Comparison and checks](../guide/comparison). | `isInteger(): bool` | No decimals other than zeros | | `isEven(): bool` | Whole number divisible by two | | `isOdd(): bool` | Whole number not divisible by two | -| `isBigInteger(): bool` | Integral part does not fit a PHP integer | +| `fitsInt(): bool` | Whole number part is within the range of a PHP integer | | `sign(): int` | `-1`, `0` or `1` | | `scale(): int` | Number of decimal places | @@ -112,7 +112,7 @@ See [Comparison and checks](../guide/comparison). | Method | Description | | --- | --- | | `toString(): string` | Exact decimal string, also used for `(string)` casts | -| `toInt(): int` | Integer without decimals. Throws `OverflowException` when out of range. | +| `toInt($roundMode = ROUND_UNNECESSARY): int` | Integer. Decimals other than zero need a `$roundMode`. Throws `OverflowException` when out of range. | | `toFloat(): float` | Nearest float. Throws `OverflowException` beyond the float range. | | `toScientific(): string` | Scientific notation such as `-2.2345e1` | | `jsonSerialize(): string` | Same as `toString()` | diff --git a/docs/reference/upgrading.md b/docs/reference/upgrading.md index ec1041e..fa9f842 100644 --- a/docs/reference/upgrading.md +++ b/docs/reference/upgrading.md @@ -87,12 +87,27 @@ See the [exception table](./api#exceptions). ### Conversion +`toInt()` no longer drops decimals on its own: + +| Call | 1.x | 2.0 | +| --- | --- | --- | +| `Decimal::create('23.74')->toInt()` | `23` | `RoundingNecessaryException` | +| `Decimal::create('23.00')->toInt()` | `23` | `23` | +| `Decimal::create('23.74')->toInt(Decimal::ROUND_DOWN)` | not available | `23` | + `toFloat()` returns the nearest float for any value in the float range. It used to throw for values with long fractions. +### Objects as input + +Only `Stringable` objects are accepted as value, which every class with a `__toString()` method is. Any other object is a `TypeError`; it was an `InvalidArgumentException` before. + +`Decimal::create()` returns the `Decimal` it was given when no scale is passed. 1.x returned a copy, which only shows in identity comparisons with `===`. + ### Removed - `greatherThanOrEquals()`, the misspelled alias. Use `greaterThanOrEquals()`. - `isBigDecimal()`. `toFloat()` no longer depends on it. +- `isBigInteger()`. Use `fitsInt()`, which answers the opposite question. - The `$strict` argument. ### Subclasses diff --git a/src/Decimal.php b/src/Decimal.php index d9d2a9b..37ac74d 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -120,11 +120,11 @@ class Decimal implements JsonSerializable, Stringable protected int $scale = 0; /** - * @param object|string|float|int $value A float is rejected, see the class description. + * @param \Stringable|string|float|int $value A float is rejected, see the class description. * @param int|null $scale Decimal places. Omit to take them from the value. Zeros are added * to reach a larger scale; a smaller one is only accepted if no digit other than zero is lost. */ - public function __construct(object|string|float|int $value, ?int $scale = null) + public function __construct(Stringable|string|float|int $value, ?int $scale = null) { $value = $this->parseValue($value); $value = $this->normalizeValue($value); @@ -143,22 +143,19 @@ public function scale(): int } /** - * @param object|string|float|int $value + * @param \Stringable|string|float|int $value * * @throws \PhpCollective\DecimalObject\Exception\InvalidNumberException * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException For a float. * * @return string */ - protected function parseValue(object|string|float|int $value): string + protected function parseValue(Stringable|string|float|int $value): string { if (is_float($value)) { throw new InvalidArgumentException('Floats are not accepted, use Decimal::createFromFloat().'); } - if (is_object($value)) { - if (!($value instanceof Stringable)) { - throw new InvalidNumberException('Invalid value'); - } + if ($value instanceof Stringable) { $value = (string)$value; } @@ -193,15 +190,15 @@ protected function normalizeValue(string $value): string * Otherwise, create a new Decimal instance from the given value and return * it. * - * @param object|string|float|int $value + * @param \Stringable|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ - public static function create(object|string|float|int $value, ?int $scale = null): static + public static function create(Stringable|string|float|int $value, ?int $scale = null): static { if ($scale === null && $value instanceof static) { - return clone $value; + return $value; } return new static($value, $scale); @@ -210,12 +207,12 @@ public static function create(object|string|float|int $value, ?int $scale = null /** * Like create(), but returns null for null and for values that are not numeric. * - * @param object|string|float|int|null $value + * @param \Stringable|string|float|int|null $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static|null */ - public static function tryCreate(object|string|float|int|null $value, ?int $scale = null): ?static + public static function tryCreate(Stringable|string|float|int|null $value, ?int $scale = null): ?static { if ($value === null) { return null; @@ -274,7 +271,7 @@ public static function one(): static } /** - * @param iterable $values + * @param iterable<\Stringable|string|float|int> $values * * @return static */ @@ -289,7 +286,7 @@ public static function sum(iterable $values): static } /** - * @param iterable $values + * @param iterable<\Stringable|string|float|int> $values * @param int $scale Decimal places in the result * @param \RoundingMode|int $roundMode One of the ROUND_* constants * @@ -313,7 +310,7 @@ public static function average(iterable $values, int $scale, RoundingMode|int $r } /** - * @param iterable $values + * @param iterable<\Stringable|string|float|int> $values * * @return static */ @@ -323,7 +320,7 @@ public static function min(iterable $values): static } /** - * @param iterable $values + * @param iterable<\Stringable|string|float|int> $values * * @return static */ @@ -333,7 +330,7 @@ public static function max(iterable $values): static } /** - * @param iterable $values + * @param iterable<\Stringable|string|float|int> $values * @param int $direction -1 for the smallest, 1 for the largest value * * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException @@ -1031,7 +1028,7 @@ protected static function roundsAwayFromZero(int $roundMode, bool $negative, boo * The shares add up to this value exactly. What cannot be divided evenly at the scale * of this value goes to the first shares, one smallest unit each. * - * @param array $ratios Non negative weights, keys are preserved. + * @param array<\Stringable|string|float|int> $ratios Non negative weights, keys are preserved. * * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException * @@ -1138,9 +1135,11 @@ public function truncate(int $scale = 0): static throw new InvalidArgumentException('Scale must be >= 0.'); } - $decimalPart = substr($this->fractionalPart, 0, $scale); + if ($scale >= $this->scale()) { + return $this; + } - return $this->copy($this->integralPart, $decimalPart); + return $this->round($scale, static::ROUND_DOWN); } /** @@ -1163,31 +1162,35 @@ public function toFloat(): float } /** - * Returns the decimal as int. Does not round. + * Returns the decimal as int. * - * This method is equivalent to a cast to int. + * @param \RoundingMode|int $roundMode Applied when the value has decimals other than zero. + * By default such a value is refused; pass ROUND_DOWN for what a cast to int does. * * @throws \PhpCollective\DecimalObject\Exception\OverflowException * * @return int */ - public function toInt(): int + public function toInt(RoundingMode|int $roundMode = self::ROUND_UNNECESSARY): int { - if ($this->isBigInteger()) { + $rounded = new static($this->roundToScale(0, static::roundMode($roundMode))); + if (!$rounded->fitsInt()) { throw new OverflowException('Value is out of range for an integer.'); } - return (int)$this->toString(); + return (int)$rounded->toString(); } /** + * Whether the whole number part is within the range of a PHP integer. + * * @return bool */ - public function isBigInteger(): bool + public function fitsInt(): bool { $value = ($this->negative ? '-' : '') . $this->integralPart; - return bccomp($value, (string)PHP_INT_MAX) === 1 || bccomp($value, (string)PHP_INT_MIN) === -1; + return bccomp($value, (string)PHP_INT_MAX) !== 1 && bccomp($value, (string)PHP_INT_MIN) !== -1; } /** diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index b36b934..727c54e 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -24,7 +24,9 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use RuntimeException; +use stdClass; use Stringable; +use TypeError; class DecimalTest extends TestCase { @@ -443,8 +445,68 @@ public function testToInt(): void $value = '-23.74'; $decimal = Decimal::create($value); - $result = $decimal->toInt(); - $this->assertSame(-23, $result); + $this->assertSame(-23, $decimal->toInt(Decimal::ROUND_DOWN)); + $this->assertSame(-24, $decimal->toInt(Decimal::ROUND_HALF_UP)); + $this->assertSame(-24, $decimal->toInt(Decimal::ROUND_FLOOR)); + $this->assertSame(5, Decimal::create('5.00')->toInt()); + } + + /** + * @return void + */ + public function testToIntRefusesDecimals(): void + { + $this->expectException(RoundingNecessaryException::class); + + Decimal::create('-23.74')->toInt(); + } + + /** + * @return void + */ + public function testToIntRoundedOutOfRange(): void + { + $value = Decimal::create(PHP_INT_MAX . '.5'); + $this->assertSame(PHP_INT_MAX, $value->toInt(Decimal::ROUND_DOWN)); + + $this->expectException(OverflowException::class); + + $value->toInt(Decimal::ROUND_HALF_UP); + } + + /** + * @return void + */ + public function testCreateReturnsSameInstance(): void + { + $value = new Decimal('1.50'); + + $this->assertSame($value, Decimal::create($value)); + $this->assertNotSame($value, Decimal::create($value, 2)); + $this->assertSame('1.500', (string)Decimal::create($value, 3)); + } + + /** + * @return void + */ + public function testObjectHasToBeStringable(): void + { + $this->expectException(TypeError::class); + + // @phpstan-ignore argument.type + new Decimal(new stdClass()); + } + + /** + * @return void + */ + public function testTruncateMatchesRoundDown(): void + { + $value = new Decimal('-12.379'); + + $this->assertSame((string)$value->round(2, Decimal::ROUND_DOWN), (string)$value->truncate(2)); + $this->assertSame('12.3', (string)(new Decimal('12.3'))->truncate(5)); + $this->assertSame('0.00', (string)(new Decimal('-0.001'))->truncate(2)); } /** @@ -1280,9 +1342,10 @@ public function testToIntBoundaries(): void { $this->assertSame(PHP_INT_MIN, (new Decimal((string)PHP_INT_MIN))->toInt()); $this->assertSame(PHP_INT_MAX, (new Decimal((string)PHP_INT_MAX))->toInt()); - $this->assertFalse((new Decimal((string)PHP_INT_MIN))->isBigInteger()); - $this->assertTrue((new Decimal('-9223372036854775809'))->isBigInteger()); - $this->assertTrue((new Decimal('9223372036854775808'))->isBigInteger()); + $this->assertTrue((new Decimal((string)PHP_INT_MIN))->fitsInt()); + $this->assertTrue((new Decimal(PHP_INT_MAX . '.9'))->fitsInt()); + $this->assertFalse((new Decimal('-9223372036854775809'))->fitsInt()); + $this->assertFalse((new Decimal('9223372036854775808'))->fitsInt()); } /**