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
10 changes: 6 additions & 4 deletions docs/guide/conversion.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
```
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/scale.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
8 changes: 4 additions & 4 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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 |

Expand All @@ -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()` |
Expand Down
15 changes: 15 additions & 0 deletions docs/reference/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 31 additions & 28 deletions src/Decimal.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -274,7 +271,7 @@ public static function one(): static
}

/**
* @param iterable<object|string|float|int> $values
* @param iterable<\Stringable|string|float|int> $values
*
* @return static
*/
Expand All @@ -289,7 +286,7 @@ public static function sum(iterable $values): static
}

/**
* @param iterable<object|string|float|int> $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
*
Expand All @@ -313,7 +310,7 @@ public static function average(iterable $values, int $scale, RoundingMode|int $r
}

/**
* @param iterable<object|string|float|int> $values
* @param iterable<\Stringable|string|float|int> $values
*
* @return static
*/
Expand All @@ -323,7 +320,7 @@ public static function min(iterable $values): static
}

/**
* @param iterable<object|string|float|int> $values
* @param iterable<\Stringable|string|float|int> $values
*
* @return static
*/
Expand All @@ -333,7 +330,7 @@ public static function max(iterable $values): static
}

/**
* @param iterable<object|string|float|int> $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
Expand Down Expand Up @@ -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<object|string|float|int> $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
*
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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;
}

/**
Expand Down
73 changes: 68 additions & 5 deletions tests/DecimalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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));
}

/**
Expand Down Expand Up @@ -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());
}

/**
Expand Down
Loading