diff --git a/docs/guide/arithmetic.md b/docs/guide/arithmetic.md index 2f2731b..a1cc353 100644 --- a/docs/guide/arithmetic.md +++ b/docs/guide/arithmetic.md @@ -1,6 +1,6 @@ # Arithmetic -Every operand can be a `Decimal`, a numeric string, an integer or a float. The result is always a new `Decimal`. +Every operand can be a `Decimal`, a numeric string or an integer. The result is always a new `Decimal`. ```php use PhpCollective\DecimalObject\Decimal; @@ -8,44 +8,63 @@ use PhpCollective\DecimalObject\Decimal; $a = Decimal::create('1.50'); ``` +## Exact by default + +No operation drops a digit on its own. Addition, subtraction and multiplication return the exact result. Where the exact result cannot be written down, as in most divisions, you say how to round. If you do not, the call throws a `RoundingNecessaryException`. + +> [!TIP] Changed in 2.0 +> Results used to be cut off at the scale without notice. See [Upgrading](../reference/upgrading#from-1-x-to-2-0). + ## Add and subtract ```php -echo $a->add('2.125'); // 3.625 +echo $a->add('2.125'); // 3.625 echo Decimal::create('5')->subtract('7.5'); // -2.5 ``` -The result has the larger scale of the two operands. +The result has the larger scale of the two operands, which is always exact. ## Multiply ```php -echo Decimal::create('1.55')->multiply('2.00'); // 3.1000 -echo Decimal::create('1.55')->multiply('2.00', 2); // 3.10 +echo Decimal::create('1.55')->multiply('2.00'); // 3.1000 +``` + +The scales of both operands are added, which is always enough to hold the exact product. + +## Asking for a scale + +`add()`, `subtract()`, `multiply()` and `mod()` take a scale as second argument and a [rounding mode](./rounding#rounding-modes) as third: + +```php +echo Decimal::create('1.55')->multiply('2.00', 2); // 3.10 +echo Decimal::create('19.99')->multiply('0.19', 2, Decimal::ROUND_HALF_UP); // 3.80 ``` -Without a scale argument the scales of both operands are added, which is always enough to hold the exact product. +The first line needs no mode because only zeros are dropped. The second would throw without one, since `3.7981` does not fit two decimals. ## Divide `divide()` requires the scale, because a quotient can have endless decimals: ```php -echo Decimal::create('10')->divide('3', 4); // 3.3333 -echo Decimal::create('2')->divide('3', 2); // 0.66 +echo Decimal::create('10')->divide('4', 2); // 2.50 ``` -The result is cut off at the scale, not rounded. Pass a rounding mode as third argument to round it: +That quotient fits. Most do not, and then a rounding mode is needed: ```php -echo Decimal::create('2')->divide('3', 2, Decimal::ROUND_HALF_UP); // 0.67 -echo Decimal::create('1')->divide('8', 2, Decimal::ROUND_HALF_EVEN); // 0.12 +Decimal::create('10')->divide('3', 4); +// RoundingNecessaryException: The result does not fit the scale. + +echo Decimal::create('10')->divide('3', 4, Decimal::ROUND_HALF_UP); // 3.3333 +echo Decimal::create('2')->divide('3', 2, Decimal::ROUND_HALF_UP); // 0.67 +echo Decimal::create('2')->divide('3', 2, Decimal::ROUND_DOWN); // 0.66 ``` -The rounding is decided on the exact quotient. All [rounding modes](./rounding#rounding-modes) work here at the scale, including floor and ceil. +The rounding is decided on the exact quotient, so ties are detected correctly at any scale. `ROUND_DOWN` gives the cut-off result of earlier versions. -> [!TIP] Added in 1.4 -> The rounding mode argument. Without it `divide()` cuts off as before. +Dividing by zero throws a `DivisionByZeroException`. ### Quotient and remainder @@ -61,10 +80,6 @@ The rounding is decided on the exact quotient. All [rounding modes](./rounding#r The quotient is cut off towards zero, so the remainder has the sign of the value, like `mod()`: `-7` by `2` gives `-3` and `-1`. Quotient times divisor plus remainder always equals the value. -> [!TIP] Added in 1.4 - -Dividing by zero throws a `DivisionByZeroError`. - ## Modulo ```php @@ -73,23 +88,46 @@ echo Decimal::create('-7')->mod(2); // -1 echo Decimal::create('10')->mod('0.3'); // 0.1 ``` -The sign follows the left operand, as with PHP's `%`. The result has the larger scale of both operands. A zero operand throws a `DivisionByZeroError`. +The sign follows the left operand, as with PHP's `%`. The result has the larger scale of both operands. A zero operand throws a `DivisionByZeroException`. + +## Power + +```php +echo Decimal::create('8')->pow(2); // 64 +echo Decimal::create('1.5')->pow(2); // 2.25 +echo Decimal::create('1.5')->pow(3); // 3.375 +``` + +A positive exponent gives the exact result. Pass a scale and a mode to shorten it: -## Power and square root +```php +echo Decimal::create('1.5')->pow(2, 1, Decimal::ROUND_HALF_UP); // 2.3 +``` + +A negative exponent is a division. It uses the scale you pass, or the scale of the value, and needs a mode unless the result fits: ```php -echo Decimal::create('8')->pow(2); // 64 -echo Decimal::create('1.5')->pow(2); // 2.2 -echo Decimal::create('1.5')->pow(2, 2); // 2.25 -echo Decimal::create('2')->pow(-1, 2); // 0.50 +echo Decimal::create('2')->pow(-1, 2); // 0.50 +echo Decimal::create('3')->pow(-1, 4, Decimal::ROUND_HALF_UP); // 0.3333 +``` -echo Decimal::create('18')->sqrt(); // 4 -echo Decimal::create('18')->sqrt(5); // 4.24264 +The exponent must be a whole number between -10000 and 10000 (`Decimal::MAX_EXPONENT`). Anything else throws an `InvalidArgumentException`. + +## Square root + +```php +echo Decimal::create('64')->sqrt(); // 8 +echo Decimal::create('2.25')->sqrt(); // 1.50 ``` -Both default to the scale of the value they are called on and cut off further digits, so pass a scale when the result needs more decimals than the input has. +The result has the scale of the value unless you pass one. A root that does not fit needs a mode: -The exponent of `pow()` must be a whole number, and `sqrt()` of a negative value is not defined. bcmath reports both with a `ValueError`. +```php +echo Decimal::create('18')->sqrt(5, Decimal::ROUND_HALF_UP); // 4.24264 +echo Decimal::create('18')->sqrt(3, Decimal::ROUND_HALF_UP); // 4.243 +``` + +A negative value throws a `NegativeNumberException`. ## Sign @@ -111,13 +149,14 @@ echo Decimal::create('12.34')->shift(-3); // 0.01234 ## Several values at once ```php -echo Decimal::sum(['1.5', 2, Decimal::create('3.25')]); // 6.75 -echo Decimal::average(['1', '2', '4'], 2); // 2.33 -echo Decimal::min(['2.5', '-1', 7]); // -1 -echo Decimal::max(['2.5', '-1', 7]); // 7 +echo Decimal::sum(['1.5', 2, Decimal::create('3.25')]); // 6.75 +echo Decimal::average(['1', '2', '3'], 2); // 2.00 +echo Decimal::average(['1', '2', '4'], 2, Decimal::ROUND_HALF_UP); // 2.33 +echo Decimal::min(['2.5', '-1', 7]); // -1 +echo Decimal::max(['2.5', '-1', 7]); // 7 ``` -All four take any iterable. `sum()` of nothing is `0`. `average()`, `min()` and `max()` throw an `InvalidArgumentException` for an empty list. `average()` needs the scale for the same reason `divide()` does, and cuts off there. +All four take any iterable. `sum()` of nothing is `0`. `average()`, `min()` and `max()` throw an `InvalidArgumentException` for an empty list. `average()` is a division: it needs the scale, and a mode when the mean does not fit. ## Splitting an amount @@ -138,9 +177,6 @@ The shares always sum to the original value. They have its scale, which also set Ratios can be integers or decimals such as `'0.7'` and `'0.3'`. Negative ratios, an empty list and ratios that sum to zero throw an `InvalidArgumentException`. -> [!TIP] Added in 1.4 -> `shift()`, `sum()`, `average()`, `min()`, `max()`, `split()` and `allocate()`. - ## Chaining ```php diff --git a/docs/guide/comparison.md b/docs/guide/comparison.md index ad2748d..e19a503 100644 --- a/docs/guide/comparison.md +++ b/docs/guide/comparison.md @@ -16,7 +16,7 @@ var_dump($value->greaterThanOrEquals('1.5')); // bool(true) var_dump($value->lessThanOrEquals('1.5')); // bool(true) ``` -Each method accepts a `Decimal`, a numeric string, an integer or a float. +Each method accepts a `Decimal`, a numeric string or an integer. `compareTo()` returns `-1`, `0` or `1` and fits `usort()`: diff --git a/docs/guide/conversion.md b/docs/guide/conversion.md index 3845158..c5b634e 100644 --- a/docs/guide/conversion.md +++ b/docs/guide/conversion.md @@ -37,7 +37,7 @@ A JSON number would be read back as a float by most clients, which defeats the p echo Decimal::create('-23.74')->toInt(); // -23 ``` -Values outside the range of a PHP integer throw a `TypeError`. Check with `isBigInteger()` first when the value may be large: +Values outside the range of a PHP integer throw an `OverflowException`. Check with `isBigInteger()` first when the value may be large: ```php $value = Decimal::create('9223372036854775808'); @@ -53,7 +53,16 @@ if (!$value->isBigInteger()) { var_dump(Decimal::create('-23.44')->toFloat()); // float(-23.44) ``` -A float is an approximation. Use it for display libraries and charts, not for further calculation. `toFloat()` throws a `TypeError` when `isBigDecimal()` is true, which is the case when the integral or the fractional digits exceed `PHP_INT_MAX`. +A float holds about 15 significant digits, so longer values come back as the nearest float: + +```php +var_dump(Decimal::create('0.12345678901234567890')->toFloat()); // float(0.12345678901234568) +``` + +Use it for display libraries and charts, not for further calculation. A value beyond the float range throws an `OverflowException`. + +> [!TIP] Changed in 2.0 +> `toFloat()` used to refuse values with long fractions, and both conversions threw a `TypeError`. See [Upgrading](../reference/upgrading#from-1-x-to-2-0). ## Scientific notation @@ -66,6 +75,17 @@ echo Decimal::create('0')->toScientific(); // 0e0 Decimals are kept as written, so the scale survives a round trip through `Decimal::create()`. Call `trim()` first for the shortest form. +## Serialization + +`serialize()` stores the decimal string only: + +```php +echo serialize(Decimal::create('19.90')); +// O:35:"PhpCollective\DecimalObject\Decimal":1:{s:5:"value";s:5:"19.90";} +``` + +Objects serialized by 1.x are still read. For storage that other systems read too, prefer the plain string. + ## Localized output The object has no formatting of its own. Pass the string to `NumberFormatter` or to your framework's number helper. Most formatters convert to float internally, so check their precision limits for large values. diff --git a/docs/guide/creating.md b/docs/guide/creating.md index 3e16576..f54a032 100644 --- a/docs/guide/creating.md +++ b/docs/guide/creating.md @@ -15,10 +15,11 @@ Both forms take the same arguments. `create()` reads better in a chain and also | --- | --- | --- | | Decimal string | `'-5.000067'` | `-5.000067` | | Integer | `-25000` | `-25000` | -| Float | `1.1` | `1.1` | | Scientific notation | `'6.22e8'`, `'2.2e-6'` | `622000000`, `0.0000022` | | `Stringable` object with a numeric string | another `Decimal` | same value | +Floats are not accepted, see [Floats](#floats). + Strings are normalized, the value stays the same: | Input | Result | @@ -35,47 +36,34 @@ Trailing zeros are kept. `'5.10'` has a scale of 2, and the object will not drop ## Invalid input -Anything that is not a plain number throws an `InvalidArgumentException`: +Anything that is not a plain number throws an `InvalidNumberException`: ```php Decimal::create('abc'); // Invalid non numeric value Decimal::create('1,5'); // Invalid non numeric value -Decimal::create(INF); // Invalid non finite value ``` Localized strings such as `'1.018,90'` are rejected. Parse them with `NumberFormatter` or your framework's locale tools first. -::: warning Floats -A float is converted with PHP's own float to string rules, which round to the `precision` ini setting (14 digits by default). That hides small errors but also drops real digits. For money and other exact values, read the number as a string from the request or the database. +::: info Exponent limit +Exponents beyond `Decimal::MAX_EXPONENT` (10000 in either direction) throw an `InvalidNumberException`. Without a limit, the twelve characters of `'1e1000000000'` would expand to a string of a billion digits. A subclass can redefine the constant. ::: -## Floats with all their digits - -When a float is all you have, `createFromFloat()` keeps every digit needed to get the same float back, independent of ini settings: - -```php -echo Decimal::create(1234567890.1234567); // 1234567890.1235 -echo Decimal::createFromFloat(1234567890.1234567); // 1234567890.1234567 - -echo Decimal::create(0.1 + 0.2); // 0.3 -echo Decimal::createFromFloat(0.1 + 0.2); // 0.30000000000000004 -``` +## Floats -The second pair shows the trade: the result is what the float really holds, including its error. +A float passed to the constructor, to `create()` or to any arithmetic method throws an `InvalidArgumentException`. A float has usually lost digits before it arrives, and the library refuses to guess which ones matter. -## Zero and one +Read exact values as strings from the request or the database. When a float is all you have, convert it on purpose: ```php -$total = Decimal::zero(); -$factor = Decimal::one(); +echo Decimal::createFromFloat(1234567890.1234567); // 1234567890.1234567 +echo Decimal::createFromFloat(0.1 + 0.2); // 0.30000000000000004 ``` -> [!TIP] Added in 1.4 -> `createFromFloat()`, `zero()` and `one()`. +`createFromFloat()` keeps every digit needed to get the same float back, independent of ini settings and locale. The second line shows what that means: you get what the float really holds, including its binary error. Round the result to the scale you expect. -::: info Exponent limit -Exponents beyond `Decimal::MAX_EXPONENT` (10000 in either direction) throw an `InvalidArgumentException`. Without a limit, the twelve characters of `'1e1000000000'` would expand to a string of a billion digits. A subclass can redefine the constant. -::: +> [!TIP] Changed in 2.0 +> Floats were accepted everywhere and cut to 14 significant digits. See [Upgrading](../reference/upgrading#from-1-x-to-2-0). ## Optional input @@ -88,41 +76,43 @@ Decimal::tryCreate(''); // null Decimal::tryCreate(null); // null ``` -This fits optional form fields and nullable columns. +This fits optional form fields and nullable columns. A float still throws, because that is a mistake in the code and not in the input. + +## Zero and one -> [!TIP] Added in 1.4 -> `tryCreate()` and the exponent limit. +```php +$total = Decimal::zero(); +$factor = Decimal::one(); +``` ## Setting a scale -The second argument fixes the number of decimal places: +The second argument fixes the number of decimal places. The string always shows it: ```php -echo Decimal::create('0.129', 2); // 0.12 +echo Decimal::create('0.1', 4); // 0.1000 +echo Decimal::create('5', 2); // 5.00 +echo Decimal::create('0.1200', 2); // 0.12 ``` -Digits beyond the scale are cut off, not rounded. A scale of `0` cuts off all decimals. Use [`round()`](./rounding) when you want rounding. - -A scale larger than the input records the scale without padding the string. Results of later calculations use it: +A smaller scale is accepted as long as only zeros are dropped. Losing any other digit throws a `RoundingNecessaryException`: ```php -$value = Decimal::create('0.1', 4); - -echo $value; // 0.1 -echo $value->scale(); // 4 -echo $value->add('0.2'); // 0.3000 +Decimal::create('0.129', 2); +// RoundingNecessaryException: The value has more than `2` decimals. ``` -### Strict mode - -Pass `true` as the third argument to refuse input that would lose digits: +Say how to round instead: ```php -Decimal::create('0.123', 2, true); -// InvalidArgumentException: Loss of precision detected. Detected scale `3` > `2` as defined. +echo Decimal::create('0.129')->withScale(2); // 0.13 +echo Decimal::create('0.129')->withScale(2, Decimal::ROUND_DOWN); // 0.12 ``` -This is useful when the scale mirrors a database column and silent truncation would hide a bug. +This matters when the scale mirrors a database column: input with too many decimals is reported instead of being shortened without notice. + +> [!TIP] Changed in 2.0 +> A scale used to cut off surplus digits and did not pad the string. The `$strict` argument is gone; its check is now always on. See [Upgrading](../reference/upgrading#from-1-x-to-2-0). ## Subclassing diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 1fa57be..8b6cf72 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -42,18 +42,18 @@ if ($gross->greaterThan('20')) { } ``` -`multiply()` returned `3.7981` here, with the scales of both operands added up. `round(2)` brings it back to two decimal places. [Precision and scale](./scale) explains the rules for each operation. +`multiply()` returned the exact product `3.7981` here, with the scales of both operands added up. `round(2)` brings it back to two decimal places. Nothing is rounded unless you ask for it. [Precision and scale](./scale) explains the rules for each operation. ## Use strings for input -Pass decimal strings whenever you can. A float has already lost digits before the object sees it: +Pass decimal strings. A float has usually lost digits before the object could see it, so floats are rejected: ```php -echo Decimal::create('1234567890.1234567'); // 1234567890.1234567 -echo Decimal::create(1234567890.1234567); // 1234567890.1235 +Decimal::create(19.90); // InvalidArgumentException +Decimal::create('19.90'); // 19.90 ``` -See [Creating decimals](./creating) for all accepted input. +See [Creating decimals](./creating) for all accepted input and for converting a float on purpose. ## Where to go next diff --git a/docs/guide/rounding.md b/docs/guide/rounding.md index 7b48e33..59d13be 100644 --- a/docs/guide/rounding.md +++ b/docs/guide/rounding.md @@ -34,6 +34,7 @@ Pass a mode as second argument. Every mode rounds at the given scale: | `Decimal::ROUND_DOWN` | towards zero | `2` | `3` | `-2` | `2` | | `Decimal::ROUND_CEIL` | towards positive infinity | `3` | `4` | `-2` | `3` | | `Decimal::ROUND_FLOOR` | towards negative infinity | `2` | `3` | `-3` | `2` | +| `Decimal::ROUND_UNNECESSARY` | refuses to round | throws | throws | throws | throws | ```php echo Decimal::create('0.125')->round(2, Decimal::ROUND_HALF_EVEN); // 0.12 @@ -44,6 +45,30 @@ echo Decimal::create('-12.375')->round(1, Decimal::ROUND_FLOOR); // -12.4 `ROUND_HALF_EVEN` is the banker's rounding used in accounting to avoid a drift over many values. +`ROUND_UNNECESSARY` asserts that nothing has to be rounded. It pads and drops zeros, and throws a `RoundingNecessaryException` for anything else. It is the default of the arithmetic methods; `round()` and `withScale()` default to `ROUND_HALF_UP`. + +### Native rounding modes + +On PHP 8.4 and newer every method that takes a mode also accepts PHP's own `RoundingMode` enum: + +```php +echo Decimal::create('2.5')->round(0, \RoundingMode::HalfEven); // 2 +echo Decimal::create('2')->divide('3', 2, \RoundingMode::HalfAwayFromZero); // 0.67 +``` + +| `RoundingMode` | Constant | +| --- | --- | +| `HalfAwayFromZero` | `ROUND_HALF_UP` | +| `HalfTowardsZero` | `ROUND_HALF_DOWN` | +| `HalfEven` | `ROUND_HALF_EVEN` | +| `HalfOdd` | `ROUND_HALF_ODD` | +| `AwayFromZero` | `ROUND_UP` | +| `TowardsZero` | `ROUND_DOWN` | +| `PositiveInfinity` | `ROUND_CEIL` | +| `NegativeInfinity` | `ROUND_FLOOR` | + +The constants keep working on every supported PHP version, so code written for PHP 8.2 needs no change later. + The constants have their own values. PHP's `PHP_ROUND_HALF_EVEN`, `PHP_ROUND_HALF_ODD` and `PHP_ROUND_HALF_DOWN` are not the same numbers. An unknown mode throws an `InvalidArgumentException`. > [!TIP] Changed in 2.0 diff --git a/docs/guide/scale.md b/docs/guide/scale.md index 1145340..34bbcb8 100644 --- a/docs/guide/scale.md +++ b/docs/guide/scale.md @@ -8,20 +8,21 @@ use PhpCollective\DecimalObject\Decimal; echo Decimal::create('1.50')->scale(); // 2 ``` -The library is strict about it: a value keeps the scale it was created with, and trailing zeros count. `1.50` and `1.5` are equal in value but carry different scales. +The library is strict about it. A value keeps the scale it was created with, trailing zeros count, and the string always shows exactly that many decimals. `1.50` and `1.5` are equal in value but carry different scales. ## Scale of a result -| Operation | Default scale | Example | +| Operation | Scale without a scale argument | Example | | --- | --- | --- | | `add()`, `subtract()`, `mod()` | larger scale of both operands | `1.50 + 2.125 = 3.625` | | `multiply()` | sum of both scales | `1.55 * 2.00 = 3.1000` | -| `divide()` | none, the argument is required | `10 / 3` at scale 4 is `3.3333` | +| `pow()` | scale times the exponent | `1.5 ** 2 = 2.25` | +| `divide()` | none, the argument is required | `10 / 4` at scale 2 is `2.50` | | `divideWithRemainder()` | 0 for the quotient, larger scale of both for the remainder | `10 / 0.3` is `33` and `0.1` | -| `pow()`, `sqrt()` | scale of the value | `1.5 ** 2 = 2.2` | -| `round()`, `truncate()` | the scale you pass | `12.375` rounded to 2 is `12.38` | +| `sqrt()` | scale of the value | root of `2.25` is `1.50` | +| `round()`, `withScale()`, `truncate()` | the scale you pass | `12.375` rounded to 2 is `12.38` | -Every arithmetic method takes a scale argument to override the default. When the exact result has more digits than the scale allows, the rest is cut off without rounding. `divide()` rounds when you pass a mode as third argument. +Every row above the line for `divide()` is exact. No digit is lost unless you ask for a smaller scale and name a [rounding mode](./rounding#rounding-modes). Asking without a mode throws a `RoundingNecessaryException` when a digit other than zero would go. ## Repeated multiplication @@ -33,19 +34,20 @@ $value = Decimal::create('1.5'); echo $value->multiply('1.5')->multiply('1.5'); // 3.375 ``` -Pass the scale you want, or round at the end of the calculation: +Pass the scale and mode you want, or round at the end of the calculation: ```php -echo Decimal::create('19.99')->multiply('0.19'); // 3.7981 -echo Decimal::create('19.99')->multiply('0.19')->round(2); // 3.80 +echo Decimal::create('19.99')->multiply('0.19'); // 3.7981 +echo Decimal::create('19.99')->multiply('0.19', 2, Decimal::ROUND_HALF_UP); // 3.80 +echo Decimal::create('19.99')->multiply('0.19')->round(2); // 3.80 ``` ## Division loses information -A cut-off quotient does not multiply back to the original: +A rounded quotient does not multiply back to the original: ```php -echo Decimal::create('100')->divide('3', 2)->multiply(3); // 99.99 +echo Decimal::create('100')->divide('3', 2, Decimal::ROUND_DOWN)->multiply(3); // 99.99 ``` To split an amount without losing a cent, use [`split()` or `allocate()`](./arithmetic#splitting-an-amount). @@ -85,4 +87,4 @@ echo Decimal::create('99999999999999999999.99')->add('0.01'); // 100000000000000000000.00 ``` -`isBigInteger()` and `isBigDecimal()` tell you whether a value still fits PHP's native types. See [Conversion](./conversion). +`isBigInteger()` 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 a13f546..0659c5f 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -2,32 +2,36 @@ Class `PhpCollective\DecimalObject\Decimal`, implementing `JsonSerializable` and `Stringable`. -`$value` arguments accept a `Decimal`, a numeric string, an integer or a float unless noted. Methods that return a `Decimal` return a new instance of the called class. +`$value` arguments accept a `Decimal`, a numeric string or an integer. A float throws, see [Floats](../guide/creating#floats). Methods that return a `Decimal` return a new instance of the called class. + +`$roundMode` arguments accept a `Decimal::ROUND_*` constant or, on PHP 8.4+, a native `RoundingMode`. ## Creation | Method | Description | | --- | --- | -| `new Decimal($value, ?int $scale = null, bool $strict = false)` | Creates a decimal. Also accepts any `Stringable` with a numeric string. | -| `Decimal::create($value, ?int $scale = null, bool $strict = false): static` | Same as the constructor, for chaining. | +| `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::tryCreate($value, ?int $scale = null): ?static` | Returns `null` for `null` and for input that is not numeric. | -| `Decimal::createFromFloat(float $value): static` | Keeps all digits of the float, independent of the `precision` ini setting. | +| `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`. | See [Creating decimals](../guide/creating). ## Arithmetic -| Method | Description | Default scale | +Results are exact. A `$scale` smaller than the exact result needs a `$roundMode`, otherwise a `RoundingNecessaryException` is thrown. + +| Method | Description | Scale without `$scale` | | --- | --- | --- | -| `add($value, ?int $scale = null): static` | Sum | larger of both | -| `subtract($value, ?int $scale = null): static` | Difference | larger of both | -| `multiply($value, ?int $scale = null): static` | Product | sum of both | -| `divide($value, int $scale, ?int $roundMode = null): static` | Quotient, cut off at `$scale`, or rounded when a mode is given | required | +| `add($value, ?int $scale = null, $roundMode = ROUND_UNNECESSARY): static` | Sum | larger of both | +| `subtract($value, ?int $scale = null, $roundMode = ROUND_UNNECESSARY): static` | Difference | larger of both | +| `multiply($value, ?int $scale = null, $roundMode = ROUND_UNNECESSARY): static` | Product | sum of both | +| `divide($value, int $scale, $roundMode = ROUND_UNNECESSARY): static` | Quotient | required | | `divideWithRemainder($value): array` | Whole number quotient and remainder | 0, and larger of both | -| `mod($value, ?int $scale = null): static` | Remainder. `$value` is a `Decimal`, string or integer. | larger of both | -| `pow($exponent, ?int $scale = null): static` | Power. `$exponent` is a whole number as `Decimal`, string or integer. | scale of the value | -| `sqrt(?int $scale = null): static` | Square root | scale of the value | +| `mod($value, ?int $scale = null, $roundMode = ROUND_UNNECESSARY): static` | Remainder | larger of both | +| `pow($exponent, ?int $scale = null, $roundMode = ROUND_UNNECESSARY): static` | Power, `$exponent` a whole number | exact for a positive exponent, scale of the value for a negative one | +| `sqrt(?int $scale = null, $roundMode = ROUND_UNNECESSARY): static` | Square root | scale of the value | | `shift(int $places): static` | Moves the decimal point, multiplying by a power of ten | follows the digits | | `absolute(): static` | Value without sign | unchanged | | `negate(): static` | Value with the opposite sign | unchanged | @@ -35,7 +39,7 @@ See [Creating decimals](../guide/creating). | Static method | Description | | --- | --- | | `Decimal::sum(iterable $values): static` | Sum, `0` for an empty list | -| `Decimal::average(iterable $values, int $scale): static` | Mean, cut off at `$scale` | +| `Decimal::average(iterable $values, int $scale, $roundMode = ROUND_UNNECESSARY): static` | Mean at `$scale` | | `Decimal::min(iterable $values): static` | Smallest value | | `Decimal::max(iterable $values): static` | Largest value | @@ -50,25 +54,27 @@ See [Arithmetic](../guide/arithmetic). | Method | Description | | --- | --- | -| `round(int $scale = 0, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds to `$scale` decimal places in the given mode. A negative scale rounds to tens, hundreds and so on. | -| `withScale(int $scale, int $roundMode = Decimal::ROUND_HALF_UP): static` | Pads or rounds to exactly `$scale` decimal places. | -| `roundToStep($step, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds to the nearest multiple of `$step`. | +| `round(int $scale = 0, $roundMode = ROUND_HALF_UP): static` | Rounds to `$scale` decimal places. A negative scale rounds to tens, hundreds and so on. | +| `withScale(int $scale, $roundMode = ROUND_HALF_UP): static` | Pads or rounds to exactly `$scale` decimal places. | +| `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`. | | `trim(): static` | Removes trailing zeros after the decimal point. | -| Constant | Value | -| --- | --- | -| `Decimal::ROUND_HALF_UP` | `1`, same as `PHP_ROUND_HALF_UP` | -| `Decimal::ROUND_CEIL` | `7` | -| `Decimal::ROUND_FLOOR` | `8` | -| `Decimal::ROUND_HALF_DOWN` | `9` | -| `Decimal::ROUND_HALF_EVEN` | `10` | -| `Decimal::ROUND_HALF_ODD` | `11` | -| `Decimal::ROUND_DOWN` | `12`, towards zero | -| `Decimal::ROUND_UP` | `13`, away from zero | -| `Decimal::MAX_EXPONENT` | `10000`, limit for scientific notation input | +| Constant | Value | Native `RoundingMode` | +| --- | --- | --- | +| `Decimal::ROUND_HALF_UP` | `1` | `HalfAwayFromZero` | +| `Decimal::ROUND_CEIL` | `7` | `PositiveInfinity` | +| `Decimal::ROUND_FLOOR` | `8` | `NegativeInfinity` | +| `Decimal::ROUND_HALF_DOWN` | `9` | `HalfTowardsZero` | +| `Decimal::ROUND_HALF_EVEN` | `10` | `HalfEven` | +| `Decimal::ROUND_HALF_ODD` | `11` | `HalfOdd` | +| `Decimal::ROUND_DOWN` | `12` | `TowardsZero` | +| `Decimal::ROUND_UP` | `13` | `AwayFromZero` | +| `Decimal::ROUND_UNNECESSARY` | `14` | none | + +`Decimal::MAX_EXPONENT` is `10000`, the limit for scientific notation input. See [Rounding](../guide/rounding). @@ -98,7 +104,6 @@ See [Comparison and checks](../guide/comparison). | `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 | -| `isBigDecimal(): bool` | `toFloat()` would refuse the value | | `sign(): int` | `-1`, `0` or `1` | | `scale(): int` | Number of decimal places | @@ -107,8 +112,8 @@ See [Comparison and checks](../guide/comparison). | Method | Description | | --- | --- | | `toString(): string` | Exact decimal string, also used for `(string)` casts | -| `toInt(): int` | Integer without decimals. Throws `TypeError` when out of range. | -| `toFloat(): float` | Float approximation. Throws `TypeError` for big decimals. | +| `toInt(): int` | Integer without decimals. 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()` | @@ -116,15 +121,23 @@ See [Conversion](../guide/conversion). ## Exceptions -| Situation | Exception | -| --- | --- | -| Input is not numeric, or a float is `INF` or `NAN` | `InvalidArgumentException` | -| Exponent beyond `Decimal::MAX_EXPONENT` | `InvalidArgumentException` | -| Negative scale in the constructor, `withScale()` or `truncate()` | `InvalidArgumentException` | -| Unknown mode in `round()` or `withScale()` | `InvalidArgumentException` | -| Empty list in `average()`, `min()`, `max()`; invalid ratios in `allocate()`; `clamp()` with `$min` above `$max` | `InvalidArgumentException` | -| Strict mode and the input has more decimals than the scale | `InvalidArgumentException` | -| `divide()`, `divideWithRemainder()` or `mod()` by zero | `DivisionByZeroError` | -| Unknown mode in `divide()` or `roundToStep()`; negative scale in `divide()` with a mode; `roundToStep()` with a step of zero or below | `InvalidArgumentException` | -| `toInt()` or `toFloat()` on a value that does not fit | `TypeError` | -| `sqrt()` of a negative value, `pow()` with a fractional exponent, negative scale in an arithmetic method | `ValueError` from bcmath | +All exceptions are in `PhpCollective\DecimalObject\Exception` and implement `DecimalException`, so one `catch` covers the library. Each also extends the SPL type listed, which keeps existing `catch` blocks for those working. + +| Exception | Extends | Thrown when | +| --- | --- | --- | +| `InvalidNumberException` | `\InvalidArgumentException` | Input is not a number, or its exponent is beyond `MAX_EXPONENT` | +| `InvalidArgumentException` | `\InvalidArgumentException` | A float is passed; negative scale; unknown rounding mode; exponent in `pow()` that is fractional or beyond `MAX_EXPONENT`; empty list in `average()`, `min()`, `max()`; invalid ratios in `allocate()`; `clamp()` with `$min` above `$max`; step of zero or below | +| `RoundingNecessaryException` | `\RuntimeException` | A result or input does not fit the scale and no rounding mode was given | +| `DivisionByZeroException` | `\DivisionByZeroError` | `divide()`, `divideWithRemainder()` or `mod()` by zero; zero raised to a negative power | +| `NegativeNumberException` | `\DomainException` | `sqrt()` of a negative value | +| `OverflowException` | `\OverflowException` | `toInt()` or `toFloat()` on a value that does not fit | + +```php +use PhpCollective\DecimalObject\Exception\DecimalException; + +try { + $rate = Decimal::create($input)->divide($total, 4); +} catch (DecimalException $e) { + // bad input, division by zero, or a quotient that needs rounding +} +``` diff --git a/docs/reference/upgrading.md b/docs/reference/upgrading.md index a63f44f..ec1041e 100644 --- a/docs/reference/upgrading.md +++ b/docs/reference/upgrading.md @@ -2,26 +2,102 @@ ## From 1.x to 2.0 -2.0 applies the corrections that change results or output for ordinary input. Review each row against your code. +2.0 has one rule behind most of its changes: the library never drops a digit unless you tell it how. Calls that used to shorten a value without notice now either return the exact result or throw. + +### Floats are rejected + +A float passed to the constructor, `create()` or any arithmetic method throws an `InvalidArgumentException`. 1.x accepted floats and cut them to 14 significant digits. + +- Pass strings: `Decimal::create('19.90')`. +- Where a float is unavoidable, use `Decimal::createFromFloat($float)` and round the result to the scale you expect. + +### Division, roots and powers need a rounding mode + +| Call | 1.x | 2.0 | +| --- | --- | --- | +| `Decimal::create('10')->divide('3', 4)` | `3.3333` | `RoundingNecessaryException` | +| `Decimal::create('10')->divide('4', 2)` | `2.50` | `2.50` | +| `Decimal::create('18')->sqrt()` | `4` | `RoundingNecessaryException` | +| `Decimal::create('64')->sqrt()` | `8` | `8` | +| `Decimal::create('1.5')->pow(2)` | `2.2` | `2.25` | +| `Decimal::average(['1', '2', '4'], 2)` | `2.33` | `RoundingNecessaryException` | + +Add `Decimal::ROUND_DOWN` as last argument to keep the 1.x result, or pick the mode you meant, for example `Decimal::ROUND_HALF_UP`. Results that fit the scale need no change. + +`pow()` with a positive exponent now returns the exact result instead of cutting it to the scale of the base. + +### A scale argument no longer cuts off + +| Call | 1.x | 2.0 | +| --- | --- | --- | +| `Decimal::create('0.129', 2)` | `0.12` | `RoundingNecessaryException` | +| `Decimal::create('0.120', 2)` | `0.12` | `0.12` | +| `Decimal::create('1.5', 0)` | `1.5` | `RoundingNecessaryException` | +| `Decimal::create('19.99')->multiply('0.19', 2)` | `3.79` | `RoundingNecessaryException` | +| `Decimal::create('1.55')->multiply('2.00', 2)` | `3.10` | `3.10` | + +`add()`, `subtract()`, `multiply()` and `mod()` take the rounding mode as third argument. For a value, round explicitly: `Decimal::create('0.129')->withScale(2, Decimal::ROUND_DOWN)`. + +The `$strict` argument of the constructor and of `create()` is removed. Its check is now always on. + +### The string shows the scale + +| Call | 1.x | 2.0 | +| --- | --- | --- | +| `(string)Decimal::create('0.1', 4)` | `0.1` | `0.1000` | +| `(string)Decimal::create('1e3', 2)` | `1000` | `1000.00` | + +Stored and serialized values gain trailing zeros where a larger scale was declared. Call `trim()` if you need the short form. + +### Rounding | Call | 1.x | 2.0 | | --- | --- | --- | -| `new Decimal('1.5', 0)` | `1.5` | `1`, throws in strict mode | | `round(0, PHP_ROUND_HALF_EVEN)` | rounds half up | `InvalidArgumentException` | | `Decimal::create('1.25')->round(1, Decimal::ROUND_FLOOR)` | `1` | `1.2` | +| `Decimal::create('15')->round(-1, Decimal::ROUND_FLOOR)` | `InvalidArgumentException` | `10` | + +Replace `PHP_ROUND_*` constants with the `Decimal::ROUND_*` ones, or with the native `RoundingMode` enum on PHP 8.4+. `round()` with `ROUND_FLOOR` or `ROUND_CEIL` and a scale above zero used to return a whole number; call `floor()` or `ceil()` if that is what you want. + +### Modulo + +| Call | 1.x | 2.0 | +| --- | --- | --- | | `Decimal::create('10')->mod('0.3')` | `0` | `0.1` | | `Decimal::create('10')->mod('3.00')` | `1` | `1.00` | -| `new Decimal('1.0e-5')` | scale 5 | scale 6 | -| `new Decimal('1e3', 2)` | `1000` | `1000.00` | -| `Decimal::create('5')->toScientific()` | `5.e0` | `5e0` | -What to do: +The result takes the larger scale of both operands. + +### Scientific notation + +A zero written in a string mantissa counts towards the scale: `'1.0e-5'` has a scale of 6. `toScientific()` no longer prints a decimal point without decimals: `5e0` where 1.x printed `5.e0`. + +### Exceptions + +The library throws its own exceptions from `PhpCollective\DecimalObject\Exception`, all implementing `DecimalException`. Most extend the type thrown before, so existing `catch` blocks keep working. These do not: + +| Situation | 1.x | 2.0 | +| --- | --- | --- | +| `toInt()` out of range | `TypeError` | `OverflowException` | +| `sqrt()` of a negative value | `ValueError` | `NegativeNumberException` | +| `pow()` with a fractional exponent | `ValueError` | `InvalidArgumentException` | +| Digits lost against a scale in strict mode | `InvalidArgumentException` | `RoundingNecessaryException` | + +See the [exception table](./api#exceptions). + +### Conversion + +`toFloat()` returns the nearest float for any value in the float range. It used to throw for values with long fractions. + +### Removed + +- `greatherThanOrEquals()`, the misspelled alias. Use `greaterThanOrEquals()`. +- `isBigDecimal()`. `toFloat()` no longer depends on it. +- The `$strict` argument. + +### Subclasses -- **Scale of 0**: a scale of `0` now cuts off decimals like any other scale. Pass `null` when you mean "no scale". -- **Rounding modes**: replace `PHP_ROUND_*` constants with the `Decimal::ROUND_*` ones. `round()` with `ROUND_FLOOR` or `ROUND_CEIL` and a scale above zero used to return a whole number; call `floor()` or `ceil()` if that is what you want. -- **Modulo**: the result takes the larger scale of both operands. Pass the scale as second argument to keep the old one. -- **Scientific notation**: a zero written in a string mantissa counts towards the scale, and `toScientific()` no longer prints a decimal point without decimals. -- **Removed**: the misspelled `greatherThanOrEquals()`. Use `greaterThanOrEquals()`. +`setScale()` lost its `$strict` parameter and `resultScale()` is gone. Serialization uses `__serialize()` and stores the decimal string; data written by 1.x is still read. ## From 1.3 to 1.4 diff --git a/phpcs.xml b/phpcs.xml index 1cf35c6..68efb1d 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -9,6 +9,7 @@ src/ tests/ + tests/stubs/ diff --git a/phpstan.neon b/phpstan.neon index aa161e6..43b65a8 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,6 +2,8 @@ parameters: level: 8 paths: - src/ + scanFiles: + - tests/stubs/RoundingMode.php ignoreErrors: - identifier: missingType.iterableValue - '#Unsafe usage of new static\(\).#' diff --git a/src/Decimal.php b/src/Decimal.php index 51aa772..d9d2a9b 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -9,12 +9,24 @@ namespace PhpCollective\DecimalObject; -use DivisionByZeroError; -use InvalidArgumentException; use JsonSerializable; +use PhpCollective\DecimalObject\Exception\DivisionByZeroException; +use PhpCollective\DecimalObject\Exception\InvalidArgumentException; +use PhpCollective\DecimalObject\Exception\InvalidNumberException; +use PhpCollective\DecimalObject\Exception\NegativeNumberException; +use PhpCollective\DecimalObject\Exception\OverflowException; +use PhpCollective\DecimalObject\Exception\RoundingNecessaryException; +use RoundingMode; use Stringable; -use TypeError; +/** + * Immutable decimal number with a fixed number of decimal places. + * + * Values are given as strings, integers or other Decimal objects. Every method that takes a + * value lists `float` in its type only to reject it with an exception: without the type, PHP + * would silently convert a float to an integer for callers that do not use strict types. + * Use createFromFloat() to convert a float on purpose. + */ class Decimal implements JsonSerializable, Stringable { /** @@ -68,6 +80,13 @@ class Decimal implements JsonSerializable, Stringable */ public const ROUND_UP = 13; + /** + * Refuses to round: throws if the result does not fit the scale exactly. + * + * @var int + */ + public const ROUND_UNNECESSARY = 14; + /** * Largest exponent accepted in scientific notation, in either direction. * @@ -101,17 +120,17 @@ class Decimal implements JsonSerializable, Stringable protected int $scale = 0; /** - * @param object|string|float|int $value - * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. - * @param bool $strict If scale should be strictly checked to avoid accidental precision loss. + * @param object|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, bool $strict = false) + public function __construct(object|string|float|int $value, ?int $scale = null) { $value = $this->parseValue($value); $value = $this->normalizeValue($value); $this->setValue($value, $scale); - $this->setScale($scale, $strict); + $this->setScale($scale); $this->normalizeZero(); } @@ -126,31 +145,25 @@ public function scale(): int /** * @param object|string|float|int $value * - * @throws \InvalidArgumentException + * @throws \PhpCollective\DecimalObject\Exception\InvalidNumberException + * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException For a float. * * @return string */ protected function parseValue(object|string|float|int $value): string { - if (!(is_scalar($value) || $value instanceof Stringable)) { - throw new InvalidArgumentException('Invalid value'); + if (is_float($value)) { + throw new InvalidArgumentException('Floats are not accepted, use Decimal::createFromFloat().'); } - - if ($value instanceof Stringable) { + if (is_object($value)) { + if (!($value instanceof Stringable)) { + throw new InvalidNumberException('Invalid value'); + } $value = (string)$value; } if (is_string($value) && !is_numeric(trim($value))) { - throw new InvalidArgumentException('Invalid non numeric value'); - } - - if (is_float($value)) { - if (!is_finite($value)) { - throw new InvalidArgumentException('Invalid non finite value'); - } - - // PHP renders large and small floats as `1.0E+25`, that zero is not a significant digit. - return str_replace('.0E', 'E', (string)$value); + throw new InvalidNumberException('Invalid non numeric value'); } return (string)$value; @@ -182,17 +195,16 @@ protected function normalizeValue(string $value): string * * @param object|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. - * @param bool $strict If scale should be strictly checked to avoid accidental precision loss. * * @return static */ - public static function create(object|string|float|int $value, ?int $scale = null, bool $strict = false): static + public static function create(object|string|float|int $value, ?int $scale = null): static { if ($scale === null && $value instanceof static) { return clone $value; } - return new static($value, $scale, $strict); + return new static($value, $scale); } /** @@ -211,7 +223,7 @@ public static function tryCreate(object|string|float|int|null $value, ?int $scal try { return static::create($value, $scale); - } catch (InvalidArgumentException) { + } catch (InvalidNumberException) { return null; } } @@ -219,19 +231,19 @@ public static function tryCreate(object|string|float|int|null $value, ?int $scal /** * Creates a decimal from a float with all the digits needed to get the same float back. * - * Passing a float to create() goes through PHP's string cast and its `precision` - * ini setting, which drops digits beyond the 14th by default. + * This is the only way to pass a float. The result holds what the float really + * stores, including its binary rounding error. * * @param float $value * - * @throws \InvalidArgumentException If the float is INF or NAN. + * @throws \PhpCollective\DecimalObject\Exception\InvalidNumberException If the float is INF or NAN. * * @return static */ public static function createFromFloat(float $value): static { if (!is_finite($value)) { - throw new InvalidArgumentException('Invalid non finite value'); + throw new InvalidNumberException('Invalid non finite value'); } $string = '0'; @@ -279,12 +291,13 @@ public static function sum(iterable $values): static /** * @param iterable $values * @param int $scale Decimal places in the result + * @param \RoundingMode|int $roundMode One of the ROUND_* constants * - * @throws \InvalidArgumentException If there are no values. + * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException If there are no values. * * @return static */ - public static function average(iterable $values, int $scale): static + public static function average(iterable $values, int $scale, RoundingMode|int $roundMode = self::ROUND_UNNECESSARY): static { $sum = new static('0'); $count = 0; @@ -296,7 +309,7 @@ public static function average(iterable $values, int $scale): static throw new InvalidArgumentException('Cannot average an empty list.'); } - return $sum->divide($count, $scale); + return $sum->divide($count, $scale, $roundMode); } /** @@ -323,7 +336,7 @@ public static function max(iterable $values): static * @param iterable $values * @param int $direction -1 for the smallest, 1 for the largest value * - * @throws \InvalidArgumentException + * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException * * @return static */ @@ -422,50 +435,48 @@ public function compareTo(self|string|float|int $value): int * Add $value to this Decimal and return the sum as a new Decimal. * * @param self|string|float|int $value - * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. + * @param int|null $scale Decimal places in the result. Omit for the larger scale of both, which is exact. + * @param \RoundingMode|int $roundMode Applied when $scale is smaller than the exact result. * * @return static */ - public function add(self|string|float|int $value, ?int $scale = null): static + public function add(self|string|float|int $value, ?int $scale = null, RoundingMode|int $roundMode = self::ROUND_UNNECESSARY): static { $decimal = static::create($value); - $scale = $this->resultScale($this, $decimal, $scale); + $exact = bcadd((string)$this, (string)$decimal, max($this->scale(), $decimal->scale())); - return new static(bcadd((string)$this, (string)$decimal, $scale)); + return $this->result($exact, $scale, $roundMode); } /** - * Return an appropriate scale for an arithmetic operation on two Decimals. - * - * If $scale is specified and is a valid positive integer, return it. - * Otherwise, return the higher of the scales of the operands. - * - * @param self $a - * @param self $b + * @param string $exact Result with all its digits * @param int|null $scale + * @param \RoundingMode|int $roundMode * - * @return int + * @return static */ - protected function resultScale(self $a, self $b, ?int $scale = null): int + protected function result(string $exact, ?int $scale, RoundingMode|int $roundMode): static { - return $scale ?? max($a->scale(), $b->scale()); + $result = new static($exact); + + return $scale === null ? $result : $result->withScale($scale, $roundMode); } /** - * Subtract $value from this Decimal and return the difference as a new - * Decimal. + * Subtract $value from this Decimal and return the difference as a new Decimal. * * @param self|string|float|int $value - * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. + * @param int|null $scale Decimal places in the result. Omit for the larger scale of both, which is exact. + * @param \RoundingMode|int $roundMode Applied when $scale is smaller than the exact result. * * @return static */ - public function subtract(self|string|float|int $value, ?int $scale = null): static + public function subtract(self|string|float|int $value, ?int $scale = null, RoundingMode|int $roundMode = self::ROUND_UNNECESSARY): static { $decimal = static::create($value); - $scale = $this->resultScale($this, $decimal, $scale); + $exact = bcsub((string)$this, (string)$decimal, max($this->scale(), $decimal->scale())); - return new static(bcsub((string)$this, (string)$decimal, $scale)); + return $this->result($exact, $scale, $roundMode); } /** @@ -602,7 +613,7 @@ public function between(self|string|float|int $min, self|string|float|int $max, * @param self|string|float|int $min * @param self|string|float|int $max * - * @throws \InvalidArgumentException If $min is greater than $max. + * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException If $min is greater than $max. * * @return static */ @@ -627,18 +638,17 @@ public function clamp(self|string|float|int $min, self|string|float|int $max): s * Multiply this Decimal by $value and return the product as a new Decimal. * * @param self|string|float|int $value - * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. + * @param int|null $scale Decimal places in the result. Omit for the sum of both scales, which is exact. + * @param \RoundingMode|int $roundMode Applied when $scale is smaller than the exact result. * * @return static */ - public function multiply(self|string|float|int $value, ?int $scale = null): static + public function multiply(self|string|float|int $value, ?int $scale = null, RoundingMode|int $roundMode = self::ROUND_UNNECESSARY): static { $decimal = static::create($value); - if ($scale === null) { - $scale = $this->scale() + $decimal->scale(); - } + $exact = bcmul((string)$this, (string)$decimal, $this->scale() + $decimal->scale()); - return new static(bcmul((string)$this, (string)$decimal, $scale)); + return $this->result($exact, $scale, $roundMode); } /** @@ -646,21 +656,19 @@ public function multiply(self|string|float|int $value, ?int $scale = null): stat * * @param self|string|float|int $value * @param int $scale Decimal places in the result - * @param int|null $roundMode One of the ROUND_* constants. Omit to cut off after $scale places. + * @param \RoundingMode|int $roundMode One of the ROUND_* constants. By default the quotient + * has to fit the scale exactly; pass ROUND_DOWN to cut it off. * - * @throws \DivisionByZeroError if $value is zero. - * @throws \InvalidArgumentException + * @throws \PhpCollective\DecimalObject\Exception\DivisionByZeroException if $value is zero. + * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException * * @return static */ - public function divide(self|string|float|int $value, int $scale, ?int $roundMode = null): static + public function divide(self|string|float|int $value, int $scale, RoundingMode|int $roundMode = self::ROUND_UNNECESSARY): static { $decimal = static::create($value); if ($decimal->isZero()) { - throw new DivisionByZeroError('Cannot divide by zero. Only Chuck Norris can!'); - } - if ($roundMode === null) { - return new static(bcdiv((string)$this, (string)$decimal, $scale)); + throw new DivisionByZeroException('Cannot divide by zero. Only Chuck Norris can!'); } if ($scale < 0) { throw new InvalidArgumentException('Scale must be >= 0.'); @@ -676,7 +684,7 @@ public function divide(self|string|float|int $value, int $scale, ?int $roundMode * * @param self|string|float|int $value * - * @throws \DivisionByZeroError if $value is zero. + * @throws \PhpCollective\DecimalObject\Exception\DivisionByZeroException if $value is zero. * * @return array{0: static, 1: static} Quotient and remainder */ @@ -684,10 +692,10 @@ public function divideWithRemainder(self|string|float|int $value): array { $decimal = static::create($value); if ($decimal->isZero()) { - throw new DivisionByZeroError('Cannot divide by zero. Only Chuck Norris can!'); + throw new DivisionByZeroException('Cannot divide by zero. Only Chuck Norris can!'); } - $scale = max($this->fractionalLength(), $decimal->fractionalLength()); + $scale = max($this->scale(), $decimal->scale()); $quotient = bcdiv((string)$this, (string)$decimal, 0); $remainder = bcsub((string)$this, bcmul($quotient, (string)$decimal, $scale), $scale); @@ -698,13 +706,13 @@ public function divideWithRemainder(self|string|float|int $value): array * Rounds to the nearest multiple of $step, for example 0.05 for cash amounts. * * @param self|string|float|int $step Greater than zero - * @param int $roundMode One of the ROUND_* constants + * @param \RoundingMode|int $roundMode One of the ROUND_* constants, or a native rounding mode on PHP 8.4+ * - * @throws \InvalidArgumentException + * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException * * @return static */ - public function roundToStep(self|string|float|int $step, int $roundMode = self::ROUND_HALF_UP): static + public function roundToStep(self|string|float|int $step, RoundingMode|int $roundMode = self::ROUND_HALF_UP): static { $step = static::create($step); if (!$step->isPositive()) { @@ -712,7 +720,7 @@ public function roundToStep(self|string|float|int $step, int $roundMode = self:: } $steps = $this->roundedQuotient($step, 0, $roundMode); - $scale = $step->fractionalLength(); + $scale = $step->scale(); return new static(bcmul($steps, (string)$step, $scale), $scale); } @@ -722,18 +730,14 @@ public function roundToStep(self|string|float|int $step, int $roundMode = self:: * @param int $scale * @param int $roundMode * - * @throws \InvalidArgumentException - * * @return string This value divided by $divisor, rounded to $scale decimal places. */ - protected function roundedQuotient(self $divisor, int $scale, int $roundMode): string + protected function roundedQuotient(self $divisor, int $scale, RoundingMode|int $roundMode): string { - if (!in_array($roundMode, static::roundModes(), true)) { - throw new InvalidArgumentException('Unsupported round mode `' . $roundMode . '`.'); - } + $roundMode = static::roundMode($roundMode); // Work on whole numbers so the remainder tells exactly whether and how to round. - $places = max($this->fractionalLength(), $divisor->fractionalLength()); + $places = max($this->scale(), $divisor->scale()); $numerator = $this->shift($places)->integralPart . str_repeat('0', $scale); $denominator = $divisor->shift($places)->integralPart; @@ -752,78 +756,114 @@ protected function roundedQuotient(self $divisor, int $scale, int $roundMode): s return (string)(new static(($negative ? '-' : '') . $quotient))->shift(-$scale); } - /** - * @return int Number of decimals actually stored, which a larger declared scale does not pad. - */ - protected function fractionalLength(): int - { - return strlen($this->fractionalPart); - } - /** * This method is equivalent to the ** operator. * - * @param self|string|int $exponent - * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. + * @param self|string|float|int $exponent A whole number up to MAX_EXPONENT in either direction + * @param int|null $scale Decimal places in the result. Omit for the exact result of a positive + * exponent, or the scale of this value for a negative one. + * @param \RoundingMode|int $roundMode Applied when $scale is smaller than the exact result. + * + * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException + * @throws \PhpCollective\DecimalObject\Exception\DivisionByZeroException * * @return static */ - public function pow(self|string|int $exponent, ?int $scale = null): static + public function pow(self|string|float|int $exponent, ?int $scale = null, RoundingMode|int $roundMode = self::ROUND_UNNECESSARY): static { - if ($scale === null) { - $scale = $this->scale(); + $exponent = static::create($exponent); + if (!$exponent->isInteger()) { + throw new InvalidArgumentException('Exponent must be a whole number.'); + } + if ($exponent->absolute()->greaterThan(static::MAX_EXPONENT)) { + throw new InvalidArgumentException('Exponent must be between -' . static::MAX_EXPONENT . ' and ' . static::MAX_EXPONENT . '.'); + } + + $times = abs($exponent->toInt()); + $power = bcpow((string)$this, (string)$times, $this->scale() * $times); + if (!$exponent->isNegative()) { + return $this->result($power, $scale, $roundMode); + } + + $power = new static($power); + if ($power->isZero()) { + throw new DivisionByZeroException('Zero cannot be raised to a negative power.'); } - return new static(bcpow((string)$this, (string)static::create($exponent), $scale)); + return static::one()->divide($power, $scale ?? $this->scale(), $roundMode); } /** - * Returns the square root of this decimal, with the same scale as this decimal. + * Returns the square root of this decimal. * - * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. + * @param int|null $scale Decimal places in the result. Omit for the scale of this value. + * @param \RoundingMode|int $roundMode One of the ROUND_* constants. By default the root + * has to fit the scale exactly. + * + * @throws \PhpCollective\DecimalObject\Exception\NegativeNumberException + * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException * * @return static */ - public function sqrt(?int $scale = null): static + public function sqrt(?int $scale = null, RoundingMode|int $roundMode = self::ROUND_UNNECESSARY): static { - if ($scale === null) { - $scale = $this->scale(); + if ($this->negative) { + throw new NegativeNumberException('Cannot take the square root of a negative number.'); + } + $scale ??= $this->scale(); + if ($scale < 0) { + throw new InvalidArgumentException('Scale must be >= 0.'); } + $roundMode = static::roundMode($roundMode); + + $value = (string)$this; + $compareScale = max($this->scale(), 2 * $scale + 2); + + // bcsqrt() cuts off, so the root is at most one last-place unit too small. + $root = bcsqrt($value, $scale); + $exact = bccomp(bcmul($root, $root, 2 * $scale), $value, $compareScale) === 0; + $middle = $root . ($scale === 0 ? '.' : '') . '5'; + $half = $exact ? -1 : bccomp($value, bcmul($middle, $middle, 2 * $scale + 2), $compareScale); + $odd = (int)substr($root, -1) % 2 === 1; - return new static(bcsqrt((string)$this, $scale)); + if (static::roundsAwayFromZero($roundMode, false, $exact, $half, $odd)) { + $root = bcadd($root, $scale > 0 ? '0.' . str_repeat('0', $scale - 1) . '1' : '1', $scale); + } + + return new static($root, $scale); } /** * This method is equivalent to the % operator. * - * @param self|string|int $value - * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. + * @param self|string|float|int $value + * @param int|null $scale Decimal places in the result. Omit for the larger scale of both, which is exact. + * @param \RoundingMode|int $roundMode Applied when $scale is smaller than the exact result. + * + * @throws \PhpCollective\DecimalObject\Exception\DivisionByZeroException if $value is zero. * * @return static */ - public function mod(self|string|int $value, ?int $scale = null): static + public function mod(self|string|float|int $value, ?int $scale = null, RoundingMode|int $roundMode = self::ROUND_UNNECESSARY): static { $decimal = static::create($value); - $scale = $this->resultScale($this, $decimal, $scale); + if ($decimal->isZero()) { + throw new DivisionByZeroException('Modulo by zero'); + } + $exact = bcmod((string)$this, (string)$decimal, max($this->scale(), $decimal->scale())); - return new static(bcmod((string)$this, (string)$decimal, $scale)); + return $this->result($exact, $scale, $roundMode); } /** * @param int $scale Decimal places in the result. Negative values round to tens, hundreds and so on. - * @param int $roundMode One of the ROUND_* constants - * - * @throws \InvalidArgumentException + * @param \RoundingMode|int $roundMode One of the ROUND_* constants, or a native rounding mode on PHP 8.4+ * * @return static */ - public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP): static + public function round(int $scale = 0, RoundingMode|int $roundMode = self::ROUND_HALF_UP): static { - if (!in_array($roundMode, static::roundModes(), true)) { - throw new InvalidArgumentException('Unsupported round mode `' . $roundMode . '`.'); - } - - return new static($this->roundToScale($scale, $roundMode), max($scale, 0)); + return new static($this->roundToScale($scale, static::roundMode($roundMode)), max($scale, 0)); } /** @@ -832,20 +872,18 @@ public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP): sta * Missing places are filled with zeros, surplus places are rounded. * * @param int $scale Decimal places in the result - * @param int $roundMode One of the ROUND_* constants + * @param \RoundingMode|int $roundMode One of the ROUND_* constants, or a native rounding mode on PHP 8.4+ * - * @throws \InvalidArgumentException + * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException * * @return static */ - public function withScale(int $scale, int $roundMode = self::ROUND_HALF_UP): static + public function withScale(int $scale, RoundingMode|int $roundMode = self::ROUND_HALF_UP): static { if ($scale < 0) { throw new InvalidArgumentException('Scale must be >= 0.'); } - if (!in_array($roundMode, static::roundModes(), true)) { - throw new InvalidArgumentException('Unsupported round mode `' . $roundMode . '`.'); - } + $roundMode = static::roundMode($roundMode); if ($scale >= strlen($this->fractionalPart)) { return $this->copy(null, str_pad($this->fractionalPart, $scale, '0')); } @@ -892,9 +930,45 @@ protected static function roundModes(): array static::ROUND_DOWN, static::ROUND_CEIL, static::ROUND_FLOOR, + static::ROUND_UNNECESSARY, ]; } + /** + * Validates a mode and maps the native enum of PHP 8.4+ to the constants of this class. + * + * The enum is matched by case name, so this file loads on PHP versions that do not have it. + * + * @param \RoundingMode|int $roundMode + * + * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException + * + * @return int + */ + protected static function roundMode(RoundingMode|int $roundMode): int + { + if (!is_int($roundMode)) { + $native = [ + 'HalfAwayFromZero' => static::ROUND_HALF_UP, + 'HalfTowardsZero' => static::ROUND_HALF_DOWN, + 'HalfEven' => static::ROUND_HALF_EVEN, + 'HalfOdd' => static::ROUND_HALF_ODD, + 'AwayFromZero' => static::ROUND_UP, + 'TowardsZero' => static::ROUND_DOWN, + 'PositiveInfinity' => static::ROUND_CEIL, + 'NegativeInfinity' => static::ROUND_FLOOR, + ]; + + return $native[$roundMode->name] + ?? throw new InvalidArgumentException('Unsupported round mode `' . $roundMode->name . '`.'); + } + if (!in_array($roundMode, static::roundModes(), true)) { + throw new InvalidArgumentException('Unsupported round mode `' . $roundMode . '`.'); + } + + return $roundMode; + } + /** * @param int $scale * @param int $roundMode @@ -925,10 +999,20 @@ protected function roundToScale(int $scale, int $roundMode): string * @param int $half -1, 0 or 1 for a rest below, at or above the half way point * @param bool $odd If the last kept digit is odd. * + * @throws \PhpCollective\DecimalObject\Exception\RoundingNecessaryException + * * @return bool If the last kept digit has to move one step away from zero. */ protected static function roundsAwayFromZero(int $roundMode, bool $negative, bool $exact, int $half, bool $odd): bool { + if ($roundMode === static::ROUND_UNNECESSARY) { + if (!$exact) { + throw new RoundingNecessaryException('The result does not fit the scale. Pass a rounding mode to round it.'); + } + + return false; + } + return match ($roundMode) { static::ROUND_FLOOR => !$exact && $negative, static::ROUND_CEIL => !$exact && !$negative, @@ -949,7 +1033,7 @@ protected static function roundsAwayFromZero(int $roundMode, bool $negative, boo * * @param array $ratios Non negative weights, keys are preserved. * - * @throws \InvalidArgumentException + * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException * * @return array */ @@ -974,7 +1058,7 @@ public function allocate(array $ratios): array throw new InvalidArgumentException('Ratios must add up to more than zero.'); } - $scale = max($this->scale(), $this->fractionalLength()); + $scale = $this->scale(); $units = $this->absolute()->shift($scale)->integralPart; $shares = []; @@ -1006,7 +1090,7 @@ public function allocate(array $ratios): array * * @param int $parts * - * @throws \InvalidArgumentException + * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException * * @return array */ @@ -1044,7 +1128,7 @@ public function ceil(): static * * @param int $scale Decimal places in the result * - * @throws \InvalidArgumentException + * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException * * @return static */ @@ -1060,23 +1144,22 @@ public function truncate(int $scale = 0): static } /** - * Return some approximation of this Decimal as a PHP native float. + * Return the nearest PHP float. * - * Due to the nature of binary floating-point, some valid values of Decimal - * will not have any finite representation as a float, and some valid - * values of Decimal will be out of the range handled by floats. + * A float holds about 15 significant digits, so this loses precision for longer values. * - * @throws \TypeError + * @throws \PhpCollective\DecimalObject\Exception\OverflowException If the value is beyond the float range. * * @return float */ public function toFloat(): float { - if ($this->isBigDecimal()) { - throw new TypeError('Cannot cast Big Decimal to Float'); + $float = (float)$this->toString(); + if (is_infinite($float)) { + throw new OverflowException('Value is out of range for a float.'); } - return (float)$this->toString(); + return $float; } /** @@ -1084,14 +1167,14 @@ public function toFloat(): float * * This method is equivalent to a cast to int. * - * @throws \TypeError + * @throws \PhpCollective\DecimalObject\Exception\OverflowException * * @return int */ public function toInt(): int { if ($this->isBigInteger()) { - throw new TypeError('Cannot cast Big Integer to Integer'); + throw new OverflowException('Value is out of range for an integer.'); } return (int)$this->toString(); @@ -1107,14 +1190,6 @@ public function isBigInteger(): bool return bccomp($value, (string)PHP_INT_MAX) === 1 || bccomp($value, (string)PHP_INT_MIN) === -1; } - /** - * @return bool - */ - public function isBigDecimal(): bool - { - return $this->isBigInteger() || bccomp($this->fractionalPart ?: '0', (string)PHP_INT_MAX) === 1; - } - /** * Returns scientific notation. * @@ -1210,6 +1285,44 @@ public function jsonSerialize(): string return $this->toString(); } + /** + * @return array{value: string} + */ + public function __serialize(): array + { + return ['value' => $this->toString()]; + } + + /** + * @param array $data + * + * @throws \PhpCollective\DecimalObject\Exception\InvalidNumberException + * + * @return void + */ + public function __unserialize(array $data): void + { + // Objects serialized by 1.x stored the properties themselves. + $legacy = "\0*\0"; + if (!isset($data['value']) && isset($data[$legacy . 'integralPart'])) { + $data = [ + 'value' => (!empty($data[$legacy . 'negative']) ? '-' : '') . $data[$legacy . 'integralPart'] + . ($data[$legacy . 'fractionalPart'] !== '' ? static::RADIX_MARK . $data[$legacy . 'fractionalPart'] : ''), + $legacy . 'scale' => $data[$legacy . 'scale'] ?? null, + ]; + } + if (!isset($data['value']) || !is_string($data['value'])) { + throw new InvalidNumberException('Invalid serialized value'); + } + + $value = $this->normalizeValue($this->parseValue($data['value'])); + $this->setValue($value, null); + // 1.x could declare more decimals than it stored, 2.0 always shows them. + $scale = $data[$legacy . 'scale'] ?? null; + $this->setScale(is_int($scale) && $scale > strlen($this->fractionalPart) ? $scale : null); + $this->normalizeZero(); + } + /** * @param string|null $integerPart * @param string|null $decimalPart @@ -1225,7 +1338,7 @@ protected function copy(?string $integerPart = null, ?string $decimalPart = null } if ($decimalPart !== null) { $clone->fractionalPart = $decimalPart; - $clone->setScale(null, false); + $clone->setScale(null); } if ($negative !== null) { $clone->negative = $negative; @@ -1266,7 +1379,7 @@ protected function setValue(string $value, ?int $scale): void /** * @param string $value * - * @throws \InvalidArgumentException + * @throws \PhpCollective\DecimalObject\Exception\InvalidNumberException * * @return void */ @@ -1274,7 +1387,7 @@ protected function fromInt(string $value): void { preg_match('/^(-)?([^.]+)$/', $value, $matches); if ($matches === []) { - throw new InvalidArgumentException('Invalid integer number'); + throw new InvalidNumberException('Invalid integer number'); } $this->negative = $matches[1] === '-'; @@ -1285,7 +1398,7 @@ protected function fromInt(string $value): void /** * @param string $value * - * @throws \InvalidArgumentException + * @throws \PhpCollective\DecimalObject\Exception\InvalidNumberException * * @return void */ @@ -1293,7 +1406,7 @@ protected function fromFloat(string $value): void { preg_match('/^(-)?(.*)\.(.*)$/', $value, $matches); if ($matches === []) { - throw new InvalidArgumentException('Invalid float number'); + throw new InvalidNumberException('Invalid float number'); } $this->negative = $matches[1] === '-'; @@ -1305,7 +1418,7 @@ protected function fromFloat(string $value): void * @param string $value * @param int|null $scale * - * @throws \InvalidArgumentException + * @throws \PhpCollective\DecimalObject\Exception\InvalidNumberException * * @return void */ @@ -1313,7 +1426,7 @@ protected function fromScientific(string $value, ?int $scale): void { $pattern = '/^(-?)(\d+)(?:' . preg_quote(static::RADIX_MARK, '/') . '(\d*))?' . static::EXP_MARK . '([+-]?\d+)$/i'; if (preg_match($pattern, $value, $matches) !== 1) { - throw new InvalidArgumentException('Invalid scientific value/notation: ' . $value); + throw new InvalidNumberException('Invalid scientific value/notation: ' . $value); } $this->negative = $matches[1] === '-'; @@ -1321,7 +1434,7 @@ protected function fromScientific(string $value, ?int $scale): void $fractionalPart = $matches[3]; $exp = (int)$matches[4]; if (abs($exp) > static::MAX_EXPONENT) { - throw new InvalidArgumentException('Exponent must be between -' . static::MAX_EXPONENT . ' and ' . static::MAX_EXPONENT . '.'); + throw new InvalidNumberException('Exponent must be between -' . static::MAX_EXPONENT . ' and ' . static::MAX_EXPONENT . '.'); } if ($exp < 0) { @@ -1335,36 +1448,37 @@ protected function fromScientific(string $value, ?int $scale): void $this->integralPart = ltrim($integralPart, '0') ?: '0'; $this->fractionalPart = $fractionalPart; - - if ($scale !== null) { - $this->fractionalPart = str_pad($this->fractionalPart, $scale, '0'); - } } /** + * Brings the decimals in line with the scale, so the string always shows it. + * * @param int|null $scale - * @param bool $strict * - * @throws \InvalidArgumentException + * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException + * @throws \PhpCollective\DecimalObject\Exception\RoundingNecessaryException * * @return void */ - protected function setScale(?int $scale, bool $strict): void + protected function setScale(?int $scale): void { - if ($scale !== null && $scale < 0) { + if ($scale === null) { + $this->scale = strlen($this->fractionalPart); + + return; + } + if ($scale < 0) { throw new InvalidArgumentException('Scale must be >= 0.'); } - $calculatedScale = strlen($this->fractionalPart); - if ($scale !== null && $calculatedScale > $scale) { - if ($strict) { - throw new InvalidArgumentException('Loss of precision detected. Detected scale `' . $calculatedScale . '` > `' . $scale . '` as defined.'); - } - - $this->fractionalPart = substr($this->fractionalPart, 0, $scale); + if (trim(substr($this->fractionalPart, $scale), '0') !== '') { + throw new RoundingNecessaryException( + 'The value has more than `' . $scale . '` decimals. Round it first, for example with withScale().', + ); } - $this->scale = $scale ?? $calculatedScale; + $this->fractionalPart = str_pad(substr($this->fractionalPart, 0, $scale), $scale, '0'); + $this->scale = $scale; } /** diff --git a/src/Exception/DecimalException.php b/src/Exception/DecimalException.php new file mode 100644 index 0000000..65c12e8 --- /dev/null +++ b/src/Exception/DecimalException.php @@ -0,0 +1,19 @@ +expectException(TypeError::class); - $this->expectExceptionMessage('Cannot cast Big Decimal to Float'); - - $decimal->toFloat(); + $this->assertSame(0.12345678901234568, Decimal::create('0.12345678901234567890')->toFloat()); + $this->assertSame(2.6922337203685477, Decimal::create('2.6' . PHP_INT_MAX)->toFloat()); } /** - * @return array + * @return void */ - public static function bigFloatDataProvider(): array + public function testToFloatOutOfRange(): void { - return [ - 'positive' => ['2.6' . PHP_INT_MAX], - 'negative' => ['-2.6' . PHP_INT_MAX], - ]; + $this->expectException(OverflowException::class); + + Decimal::create('1e400')->toFloat(); } /** @@ -457,8 +456,7 @@ public function testToIntForBigIntThrowsAnException(string $value): void { $decimal = Decimal::create($value); - $this->expectException(TypeError::class); - $this->expectExceptionMessage('Cannot cast Big Integer to Integer'); + $this->expectException(OverflowException::class); $decimal->toInt(); } @@ -512,13 +510,13 @@ public function testSqrt(): void $value = '18'; $decimal = Decimal::create($value); - $result = $decimal->sqrt(5); + $result = $decimal->sqrt(5, Decimal::ROUND_DOWN); $this->assertSame('4.24264', (string)$result); $value = '18.000000'; $decimal = Decimal::create($value); - $result = $decimal->sqrt(); + $result = $decimal->sqrt(null, Decimal::ROUND_DOWN); $this->assertSame('4.242640', (string)$result); } @@ -650,13 +648,13 @@ public static function roundProvider(): array ['12.364', 2, '12.36'], ['12.365', 2, '12.37'], ['-13.574', 0, '-14'], - [13.4999, 0, '13'], - [13.4999, 10, '13.4999000000'], - [13.4999, 2, '13.50'], - [0.0001, 0, '0'], - [-0.0001, 0, '0'], - [0.9999, 0, '1'], - [-0.9999, 0, '-1'], + ['13.4999', 0, '13'], + ['13.4999', 10, '13.4999000000'], + ['13.4999', 2, '13.50'], + ['0.0001', 0, '0'], + ['-0.0001', 0, '0'], + ['0.9999', 0, '1'], + ['-0.9999', 0, '-1'], ]; } @@ -708,8 +706,8 @@ public static function floorProvider(): array ['13.6999', '13'], ['13.1', '13'], ['13.9', '13'], - [0.0001, '0'], - [-0.0001, '-1'], + ['0.0001', '0'], + ['-0.0001', '-1'], ]; } @@ -760,8 +758,8 @@ public static function ceilProvider(): array ['13.6999', '14'], ['13.1', '14'], ['13.9', '14'], - [0.0001, '1'], - [-0.0001, '0'], + ['0.0001', '1'], + ['-0.0001', '0'], ]; } @@ -882,7 +880,7 @@ public function testSubtract(): void public function testMultiply(mixed $a, mixed $b, ?int $scale, string $expected): void { $decimal = Decimal::create($a); - $this->assertSame($expected, (string)$decimal->multiply($b, $scale)); + $this->assertSame($expected, (string)$decimal->multiply($b, $scale, Decimal::ROUND_DOWN)); } /** @@ -921,7 +919,7 @@ public static function multiplicationProvider(): array public function testMultiplyLegacy(mixed $a, mixed $b, ?int $scale, string $expected): void { $decimal = Decimal::create($a); - $this->assertSame($expected, (string)$decimal->multiply($b, $scale)); + $this->assertSame($expected, (string)$decimal->multiply($b, $scale, Decimal::ROUND_DOWN)); } /** @@ -946,7 +944,7 @@ public static function multiplicationLegacyProvider(): array public function testDivide(mixed $a, mixed $b, int $scale, string $expected): void { $decimal = Decimal::create($a); - $this->assertSame($expected, (string)$decimal->divide($b, $scale)); + $this->assertSame($expected, (string)$decimal->divide($b, $scale, Decimal::ROUND_DOWN)); } /** @@ -1017,9 +1015,9 @@ public function testDebugInfo(): void public function testPrecisionLoss(): void { $a = Decimal::create('0.123456789', 10); - $this->assertSame('0.123456789', (string)$a); + $this->assertSame('0.1234567890', (string)$a); - $b = Decimal::create((string)$a, 8); + $b = $a->withScale(8, Decimal::ROUND_DOWN); $this->assertSame('0.12345678', (string)$b); } @@ -1028,16 +1026,16 @@ public function testPrecisionLoss(): void */ public function testPrecisionLossProtection(): void { - $a = Decimal::create('0.1', 50, true); + $a = Decimal::create('0.1', 50); $this->assertSame(50, $a->scale()); - $b = Decimal::create($a, null, true); + $b = Decimal::create($a); $this->assertSame(50, $b->scale()); - $c = Decimal::create($b, 6, true); // Not 50 if manually overwritten + $c = Decimal::create($b, 6); // Only zeros are dropped $this->assertSame(6, $c->scale()); - $d = Decimal::create($c, 64, true); + $d = Decimal::create($c, 64); $this->assertSame(64, $d->scale()); } @@ -1046,10 +1044,9 @@ public function testPrecisionLossProtection(): void */ public function testPrecisionLossFail(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Loss of precision detected'); + $this->expectException(RoundingNecessaryException::class); - Decimal::create('0.123', 2, true); + Decimal::create('0.123', 2); } /** @@ -1090,9 +1087,6 @@ public static function normalizationProvider(): array 'zero exponent' => ['1.5e0', '1.5'], 'negative zero exponent' => ['-0e5', '0'], 'zero mantissa' => ['0.0e-3', '0.0000'], - 'large float' => [1e25, '10000000000000000000000000'], - 'small float' => [1.5e-7, '0.00000015'], - 'negative zero float' => [-0.0, '0'], ]; } @@ -1126,7 +1120,7 @@ public static function nonFiniteProvider(): array */ public function testNewObjectZeroScale(): void { - $decimal = new Decimal('1.56', 0); + $decimal = new Decimal('1.00', 0); $this->assertSame('1', (string)$decimal); $this->assertSame(0, $decimal->scale()); } @@ -1134,12 +1128,11 @@ public function testNewObjectZeroScale(): void /** * @return void */ - public function testNewObjectZeroScaleStrict(): void + public function testNewObjectZeroScaleLosingDigits(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Loss of precision detected'); + $this->expectException(RoundingNecessaryException::class); - new Decimal('1.56', 0, true); + new Decimal('1.56', 0); } /** @@ -1159,7 +1152,7 @@ public function testNewObjectScientificWithScale(): void { $this->assertSame('1.500', (string)new Decimal('1.5e0', 3)); $this->assertSame('0.001200', (string)new Decimal('1.2e-3', 6)); - $this->assertSame('0.0012', (string)new Decimal('1.23456e-3', 4)); + $this->assertSame('1000.00', (string)new Decimal('1e3', 2)); } /** @@ -1175,7 +1168,7 @@ public function testNoNegativeZero(): void $this->assertSame('0.00', (string)(new Decimal('0.00'))->negate()); $this->assertSame('0', (string)(new Decimal('-0.5'))->truncate()); $this->assertSame('0.0', (string)(new Decimal('-0.05'))->truncate(1)); - $this->assertSame('0.00', (string)new Decimal('-0.001', 2)); + $this->assertSame('0.00', (string)(new Decimal('-0.001'))->withScale(2)); $this->assertFalse((new Decimal('-0.5'))->truncate()->isNegative()); } @@ -1302,7 +1295,7 @@ public function testModOperands(): void $this->assertSame('1', (string)$decimal->mod(new Decimal('3'))); $this->assertSame('0.1', (string)$decimal->mod('0.3')); $this->assertSame('1.5', (string)(new Decimal('10.5'))->mod('3')); - $this->assertSame('0', (string)$decimal->mod('0.3', 0)); + $this->assertSame('0', (string)$decimal->mod('0.3', 0, Decimal::ROUND_DOWN)); } /** @@ -1315,7 +1308,8 @@ public function testPowOperands(): void $this->assertSame('100', (string)(new Decimal('10'))->pow('2e0')); $this->assertSame('4', (string)$decimal->pow(new Decimal('2'))); $this->assertSame('0.50', (string)$decimal->pow(-1, 2)); - $this->assertSame('2.25', (string)(new Decimal('1.50'))->pow(2)); + $this->assertSame('2.2500', (string)(new Decimal('1.50'))->pow(2)); + $this->assertSame('2.3', (string)(new Decimal('1.50'))->pow(2, 1, Decimal::ROUND_HALF_UP)); } /** @@ -1508,12 +1502,11 @@ public static function exponentLimitProvider(): array public function testTryCreate(): void { $this->assertSame('1.50', (string)Decimal::tryCreate('1.50')); - $this->assertSame('1.5', (string)Decimal::tryCreate('1.56', 1)); + $this->assertSame('1.50', (string)Decimal::tryCreate('1.5', 2)); $this->assertNull(Decimal::tryCreate(null)); $this->assertNull(Decimal::tryCreate('')); $this->assertNull(Decimal::tryCreate('abc')); $this->assertNull(Decimal::tryCreate('1,5')); - $this->assertNull(Decimal::tryCreate(NAN)); } /** @@ -1532,7 +1525,7 @@ public function testSum(): void public function testAverage(): void { $this->assertSame('2.00', (string)Decimal::average(['1', '2', '3'], 2)); - $this->assertSame('0.333', (string)Decimal::average(['0', '0', '1'], 3)); + $this->assertSame('0.333', (string)Decimal::average(['0', '0', '1'], 3, Decimal::ROUND_DOWN)); } /** @@ -1807,10 +1800,20 @@ public static function divideRoundedProvider(): array /** * @return void */ - public function testDivideWithoutModeCutsOff(): void + public function testDivideExactNeedsNoMode(): void + { + $this->assertSame('2.50', (string)(new Decimal('10'))->divide('4', 2)); + $this->assertSame('0.66', (string)(new Decimal('2'))->divide('3', 2, Decimal::ROUND_DOWN)); + } + + /** + * @return void + */ + public function testDivideInexactWithoutMode(): void { - $this->assertSame('0.66', (string)(new Decimal('2'))->divide('3', 2)); - $this->assertSame('0.66', (string)(new Decimal('2'))->divide('3', 2, null)); + $this->expectException(RoundingNecessaryException::class); + + (new Decimal('2'))->divide('3', 2); } /** @@ -2018,13 +2021,478 @@ public function testZeroAndOne(): void } /** + * @param string $value + * @param int|null $scale + * @param string $expected + * + * @return void + */ + #[DataProvider('declaredScaleProvider')] + public function testStringShowsDeclaredScale(string $value, ?int $scale, string $expected): void + { + $decimal = new Decimal($value, $scale); + + $this->assertSame($expected, (string)$decimal); + $this->assertSame(strlen(explode('.', $expected . '.')[1]), $decimal->scale()); + } + + /** + * @return array + */ + public static function declaredScaleProvider(): array + { + return [ + 'padded' => ['0.1', 4, '0.1000'], + 'integer padded' => ['5', 2, '5.00'], + 'detected' => ['0.10', null, '0.10'], + 'zeros dropped' => ['0.1200', 2, '0.12'], + 'all decimals dropped' => ['7.000', 0, '7'], + 'scientific padded' => ['1.2e-3', 6, '0.001200'], + 'negative zero padded' => ['-0', 2, '0.00'], + ]; + } + + /** + * @return void + */ + public function testDeclaredScaleCarriesThroughArithmetic(): void + { + $value = Decimal::create('0.1', 4); + + $this->assertSame('0.3000', (string)$value->add('0.2')); + $this->assertSame('0.1', (string)$value->trim()); + $this->assertSame('0.1000', (string)Decimal::create($value)); + $this->assertSame('0.10', (string)Decimal::create($value, 2)); + $this->assertSame('{"v":"0.1000"}', json_encode(['v' => $value])); + } + + /** + * @param mixed $float + * + * @return void + */ + #[DataProvider('floatProvider')] + public function testFloatIsRejected(mixed $float): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('createFromFloat'); + + new Decimal($float); + } + + /** + * @return array + */ + public static function floatProvider(): array + { + return [ + 'fraction' => [1.5], + 'whole' => [2.0], + 'nan' => [NAN], + ]; + } + + /** + * @return void + */ + public function testFloatOperandIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('createFromFloat'); + + (new Decimal('1'))->add(0.5); + } + + /** + * @return void + */ + public function testTryCreateDoesNotHideFloat(): void + { + $this->expectException(InvalidArgumentException::class); + + Decimal::tryCreate(1.5); + } + + /** + * @param string $operation + * @param string $a + * @param string $b + * @param int|null $scale + * @param int $mode + * @param string $expected + * + * @return void + */ + #[DataProvider('scaledArithmeticProvider')] + public function testArithmeticWithScale(string $operation, string $a, string $b, ?int $scale, int $mode, string $expected): void + { + $this->assertSame($expected, (string)(new Decimal($a))->$operation($b, $scale, $mode)); + } + + /** + * @return array + */ + public static function scaledArithmeticProvider(): array + { + $none = Decimal::ROUND_UNNECESSARY; + + return [ + 'add exact' => ['add', '0.15', '0.2', null, $none, '0.35'], + 'add padded' => ['add', '0.15', '0.2', 4, $none, '0.3500'], + 'add zeros dropped' => ['add', '0.15', '0.25', 1, $none, '0.4'], + 'add rounded' => ['add', '0.15', '0.2', 1, Decimal::ROUND_HALF_UP, '0.4'], + 'add cut off' => ['add', '0.15', '0.2', 1, Decimal::ROUND_DOWN, '0.3'], + 'subtract rounded' => ['subtract', '1', '0.345', 2, Decimal::ROUND_HALF_EVEN, '0.66'], + 'multiply exact' => ['multiply', '1.55', '2.00', null, $none, '3.1000'], + 'multiply zeros dropped' => ['multiply', '1.55', '2.00', 2, $none, '3.10'], + 'multiply rounded' => ['multiply', '19.99', '0.19', 2, Decimal::ROUND_HALF_UP, '3.80'], + 'multiply cut off' => ['multiply', '19.99', '0.19', 2, Decimal::ROUND_DOWN, '3.79'], + 'mod exact' => ['mod', '10', '0.3', null, $none, '0.1'], + 'mod padded' => ['mod', '10', '3', 2, $none, '1.00'], + 'mod cut off' => ['mod', '10', '0.3', 0, Decimal::ROUND_DOWN, '0'], + ]; + } + + /** + * @param string $operation + * @param string $a + * @param string $b + * @param int $scale + * + * @return void + */ + #[DataProvider('lossyArithmeticProvider')] + public function testArithmeticRefusesToLoseDigits(string $operation, string $a, string $b, int $scale): void + { + $this->expectException(RoundingNecessaryException::class); + + (new Decimal($a))->$operation($b, $scale); + } + + /** + * @return array + */ + public static function lossyArithmeticProvider(): array + { + return [ + 'add' => ['add', '0.15', '0.2', 1], + 'subtract' => ['subtract', '1', '0.345', 2], + 'multiply' => ['multiply', '19.99', '0.19', 2], + 'mod' => ['mod', '10', '0.3', 0], + 'divide' => ['divide', '1', '3', 5], + 'pow' => ['pow', '1.5', '2', 1], + 'pow negative' => ['pow', '3', '-1', 4], + ]; + } + + /** + * @param string $value + * @param string $exponent + * @param int|null $scale + * @param int $mode + * @param string $expected + * + * @return void + */ + #[DataProvider('powProvider')] + public function testPowScale(string $value, string $exponent, ?int $scale, int $mode, string $expected): void + { + $this->assertSame($expected, (string)(new Decimal($value))->pow($exponent, $scale, $mode)); + } + + /** + * @return array + */ + public static function powProvider(): array + { + $none = Decimal::ROUND_UNNECESSARY; + + return [ + 'exact' => ['1.5', '2', null, $none, '2.25'], + 'exact cube' => ['1.5', '3', null, $none, '3.375'], + 'zero exponent' => ['1.5', '0', null, $none, '1'], + 'integer' => ['8', '2', null, $none, '64'], + 'negative base' => ['-2', '3', null, $none, '-8'], + 'rounded' => ['1.5', '2', 1, Decimal::ROUND_HALF_UP, '2.3'], + 'cut off' => ['1.5', '2', 1, Decimal::ROUND_DOWN, '2.2'], + 'padded' => ['8', '2', 2, $none, '64.00'], + 'negative exponent exact' => ['2', '-1', 2, $none, '0.50'], + 'negative exponent own scale' => ['4.00', '-1', null, $none, '0.25'], + 'negative exponent rounded' => ['3', '-1', 4, Decimal::ROUND_HALF_UP, '0.3333'], + 'negative exponent rounded up' => ['3', '-2', 3, Decimal::ROUND_UP, '0.112'], + 'whole exponent with decimals' => ['2', '2.0', null, $none, '4'], + ]; + } + + /** + * @param string $exponent + * * @return void */ - public function testAllocateWithZeroScaleDeclared(): void + #[DataProvider('invalidExponentProvider')] + public function testPowInvalidExponent(string $exponent): void { - $decimal = new Decimal('1.5', 0); - $shares = $decimal->split(2); + $this->expectException(InvalidArgumentException::class); + + (new Decimal('2'))->pow($exponent); + } + + /** + * @return array + */ + public static function invalidExponentProvider(): array + { + return [ + 'fraction' => ['0.5'], + 'too large' => ['9223372036854775808'], + 'beyond limit' => ['10001'], + 'beyond negative limit' => ['-10001'], + 'smallest integer' => [(string)PHP_INT_MIN], + ]; + } + + /** + * @return void + */ + public function testPowZeroToNegative(): void + { + $this->expectException(DivisionByZeroException::class); + + (new Decimal('0'))->pow(-1, 2); + } + + /** + * @param string $value + * @param int|null $scale + * @param int $mode + * @param string $expected + * + * @return void + */ + #[DataProvider('sqrtProvider')] + public function testSqrtRounding(string $value, ?int $scale, int $mode, string $expected): void + { + $this->assertSame($expected, (string)(new Decimal($value))->sqrt($scale, $mode)); + } + + /** + * @return array + */ + public static function sqrtProvider(): array + { + $none = Decimal::ROUND_UNNECESSARY; + + return [ + 'exact' => ['64', null, $none, '8'], + 'exact decimal' => ['2.25', 1, $none, '1.5'], + 'exact own scale' => ['2.2500', null, $none, '1.5000'], + 'exact padded' => ['64', 2, $none, '8.00'], + 'zero' => ['0.00', null, $none, '0.00'], + 'down' => ['18', 5, Decimal::ROUND_DOWN, '4.24264'], + 'half up stays' => ['18', 5, Decimal::ROUND_HALF_UP, '4.24264'], + 'half up moves' => ['18', 3, Decimal::ROUND_HALF_UP, '4.243'], + 'up' => ['18', 0, Decimal::ROUND_UP, '5'], + 'ceil' => ['2', 2, Decimal::ROUND_CEIL, '1.42'], + 'floor' => ['2', 2, Decimal::ROUND_FLOOR, '1.41'], + 'half even' => ['2', 4, Decimal::ROUND_HALF_EVEN, '1.4142'], + 'large' => ['1000000000000000000000000000000', null, $none, '1000000000000000'], + ]; + } + + /** + * @return void + */ + public function testSqrtInexactWithoutMode(): void + { + $this->expectException(RoundingNecessaryException::class); + + (new Decimal('18'))->sqrt(); + } + + /** + * @return void + */ + public function testSqrtNegative(): void + { + $this->expectException(NegativeNumberException::class); + + (new Decimal('-4'))->sqrt(); + } + + /** + * @return void + */ + public function testRoundUnnecessary(): void + { + $this->assertSame('1.50', (string)(new Decimal('1.5'))->round(2, Decimal::ROUND_UNNECESSARY)); + $this->assertSame('1.5', (string)(new Decimal('1.500'))->withScale(1, Decimal::ROUND_UNNECESSARY)); + + $this->expectException(RoundingNecessaryException::class); + + (new Decimal('1.25'))->round(1, Decimal::ROUND_UNNECESSARY); + } + + /** + * @return void + */ + public function testModByZero(): void + { + $this->expectException(DivisionByZeroException::class); + + (new Decimal('10'))->mod('0.0'); + } + + /** + * @param string $method + * @param array $arguments + * @param string $exception + * @param string $parent + * + * @return void + */ + #[DataProvider('exceptionProvider')] + public function testExceptionHierarchy(string $method, array $arguments, string $exception, string $parent): void + { + try { + if ($method === 'create') { + Decimal::create(...$arguments); + } else { + (new Decimal('-2.5'))->$method(...$arguments); + } + $this->fail('No exception thrown'); + } catch (DecimalException $e) { + $this->assertInstanceOf($exception, $e); + $this->assertInstanceOf($parent, $e); + } + } + + /** + * @return array + */ + public static function exceptionProvider(): array + { + return [ + 'not numeric' => ['create', ['abc'], InvalidNumberException::class, InvalidArgumentException::class], + 'exponent limit' => ['create', ['1e99999'], InvalidNumberException::class, InvalidArgumentException::class], + 'negative scale' => ['create', ['1', -1], InvalidArgumentException::class, InvalidArgumentException::class], + 'unknown mode' => ['round', [0, 99], InvalidArgumentException::class, InvalidArgumentException::class], + 'digits lost' => ['divide', [3, 2], RoundingNecessaryException::class, RuntimeException::class], + 'division by zero' => ['divide', [0, 2], DivisionByZeroException::class, DivisionByZeroError::class], + 'modulo by zero' => ['mod', [0], DivisionByZeroException::class, DivisionByZeroError::class], + 'root of negative' => ['sqrt', [], NegativeNumberException::class, DomainException::class], + 'fractional exponent' => ['pow', ['0.5'], InvalidArgumentException::class, InvalidArgumentException::class], + ]; + } + + /** + * @return void + */ + public function testInvalidNumberIsAnInvalidArgument(): void + { + $this->expectException(InvalidArgumentException::class); + + Decimal::create('abc'); + } + + /** + * @param string $value + * + * @return void + */ + #[DataProvider('serializeProvider')] + public function testSerialize(string $value): void + { + $decimal = new Decimal($value); + $restored = unserialize(serialize($decimal)); + + $this->assertInstanceOf(Decimal::class, $restored); + $this->assertSame($value, (string)$restored); + $this->assertSame($decimal->scale(), $restored->scale()); + $this->assertStringContainsString('"value"', serialize($decimal)); + $this->assertStringNotContainsString('integralPart', serialize($decimal)); + } + + /** + * @return array + */ + public static function serializeProvider(): array + { + return [ + ['0'], + ['-1.50'], + ['0.00'], + ['123456789012345678901234567890.000100'], + ]; + } + + /** + * @return void + */ + public function testUnserializeFromVersionOne(): void + { + $legacy = "O:35:\"PhpCollective\\DecimalObject\\Decimal\":4:{s:15:\"\0*\0integralPart\";s:1:\"1\";" + . "s:17:\"\0*\0fractionalPart\";s:2:\"50\";s:11:\"\0*\0negative\";b:1;s:8:\"\0*\0scale\";i:2;}"; + + $restored = unserialize($legacy); + + $this->assertInstanceOf(Decimal::class, $restored); + $this->assertSame('-1.50', (string)$restored); + $this->assertSame(2, $restored->scale()); + } + + /** + * @return void + */ + public function testUnserializeFromVersionOneWithDeclaredScale(): void + { + $legacy = "O:35:\"PhpCollective\\DecimalObject\\Decimal\":4:{s:15:\"\0*\0integralPart\";s:1:\"1\";" + . "s:17:\"\0*\0fractionalPart\";s:0:\"\";s:11:\"\0*\0negative\";b:0;s:8:\"\0*\0scale\";i:2;}"; + + $restored = unserialize($legacy); + + $this->assertInstanceOf(Decimal::class, $restored); + $this->assertSame('1.00', (string)$restored); + $this->assertSame(['0.50', '0.50'], array_map('strval', $restored->split(2))); + } + + /** + * @return void + */ + public function testUnserializeInvalid(): void + { + $this->expectException(InvalidNumberException::class); + + unserialize('O:35:"PhpCollective\DecimalObject\Decimal":1:{s:5:"value";s:3:"abc";}'); + } + + /** + * @return void + */ + public function testNativeRoundingMode(): void + { + if (!enum_exists('RoundingMode')) { + $this->markTestSkipped('The native RoundingMode enum needs PHP 8.4.'); + } + + $cases = [ + 'HalfAwayFromZero' => '3', + 'HalfTowardsZero' => '2', + 'HalfEven' => '2', + 'HalfOdd' => '3', + 'TowardsZero' => '2', + 'AwayFromZero' => '3', + 'NegativeInfinity' => '2', + 'PositiveInfinity' => '3', + ]; + $decimal = new Decimal('2.5'); + foreach ($cases as $name => $expected) { + $mode = constant('RoundingMode::' . $name); + + $this->assertSame($expected, (string)$decimal->round(0, $mode), $name); + $this->assertSame($expected, (string)$decimal->withScale(0, $mode), $name); + $this->assertSame($expected, (string)(new Decimal('5'))->divide(2, 0, $mode), $name); + } - $this->assertSame(['1', '0'], array_map('strval', $shares)); + $this->assertSame(count($cases), count(constant('RoundingMode::HalfEven')::cases())); } } diff --git a/tests/stubs/RoundingMode.php b/tests/stubs/RoundingMode.php new file mode 100644 index 0000000..f4d4652 --- /dev/null +++ b/tests/stubs/RoundingMode.php @@ -0,0 +1,23 @@ +