From 81e91500ea2340e1d667829bbcd4f978d80ad51f Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 16:45:52 +0200 Subject: [PATCH] Add rounding modes, withScale(), allocation and helper methods All additions. A run of the previous and the new class over the same 10,000 calls differs in one place only: round() with a negative scale returned an exception and now rounds to tens, hundreds and so on. Rounding - ROUND_HALF_DOWN, ROUND_HALF_EVEN, ROUND_HALF_ODD, ROUND_UP and ROUND_DOWN. They get their own values (9 to 13) and not those of the PHP_ROUND_* constants: round() treats unknown modes as half up in 1.x, so reusing 2, 3 and 4 would silently change the result for callers that pass PHP_ROUND_HALF_EVEN today. - withScale() pads or rounds to an exact scale and applies every mode at that scale, including floor and ceil, which round() cannot do in 1.x. - round() accepts a negative scale for the non-directed modes. Input - Scientific notation is limited to exponents up to MAX_EXPONENT (10000). "1e1000000000" used to allocate a billion digits. - tryCreate() returns null for null and non numeric input. Calculation - shift() moves the decimal point. - sum(), average(), min() and max() over any iterable. - allocate() and split() divide a value into shares that sum to it exactly, at the scale of the value. - between(), clamp(), isEven() and isOdd(). All bcmath calls in the new code pass their scale. A global bcscale() set by the application would otherwise leak into the results. --- docs/guide/arithmetic.md | 43 ++++ docs/guide/comparison.md | 25 ++ docs/guide/creating.md | 20 +- docs/guide/rounding.md | 68 +++++- docs/guide/scale.md | 13 +- docs/reference/api.md | 32 ++- docs/reference/upgrading.md | 27 ++- src/Decimal.php | 413 ++++++++++++++++++++++++++++++-- tests/DecimalTest.php | 454 +++++++++++++++++++++++++++++++++++- 9 files changed, 1045 insertions(+), 50 deletions(-) diff --git a/docs/guide/arithmetic.md b/docs/guide/arithmetic.md index eaa28fa..d0be489 100644 --- a/docs/guide/arithmetic.md +++ b/docs/guide/arithmetic.md @@ -80,6 +80,49 @@ echo Decimal::create('-23.44')->absolute(); // 23.44 echo Decimal::create('23.44')->negate(); // -23.44 ``` +## Moving the decimal point + +`shift()` multiplies by a power of ten without touching the digits. Use it to convert between units, such as an amount and its cents: + +```php +echo Decimal::create('19.99')->shift(2); // 1999 +echo Decimal::create('1999')->shift(-2); // 19.99 +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 +``` + +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. + +## Splitting an amount + +Dividing an amount and rounding each share rarely adds up to the original. `split()` and `allocate()` hand out every smallest unit: + +```php +$shares = Decimal::create('100.00')->split(3); +// 33.34, 33.33, 33.33 + +$shares = Decimal::create('100.00')->allocate([70, 30]); +// 70.00, 30.00 + +$shares = Decimal::create('10.00')->allocate(['rent' => 2, 'food' => 1]); +// ['rent' => 6.67, 'food' => 3.33] +``` + +The shares always sum to the original value. They have its scale, which also sets the smallest unit: `10` splits into `4, 3, 3`. What is left after the even division goes to the first shares, one unit each. Keys of the ratio array are kept, and a ratio of `0` receives nothing. + +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 e27a6c1..ad2748d 100644 --- a/docs/guide/comparison.md +++ b/docs/guide/comparison.md @@ -27,6 +27,20 @@ usort($values, fn (Decimal $a, Decimal $b) => $a->compareTo($b)); // -1, 2.05, 2.5 ``` +## Ranges + +```php +var_dump(Decimal::create('5')->between(1, 10)); // bool(true) +var_dump(Decimal::create('5')->between(5, 10, false)); // bool(false) + +echo Decimal::create('12')->clamp(0, 10); // 10 +echo Decimal::create('-3')->clamp(0, 10); // 0 +``` + +`between()` includes both bounds unless the third argument is `false`. `clamp()` returns the nearest bound for a value outside the range and throws an `InvalidArgumentException` when the lower bound is above the upper one. + +> [!TIP] Added in 1.4 + ::: warning Do not use == or === `==` on two objects compares their properties, so `1.50 == 1.5` is `false`. `===` compares identity. Use `equals()`. ::: @@ -50,3 +64,14 @@ Zero is neither positive nor negative. var_dump(Decimal::create('5.00')->isInteger()); // bool(true) var_dump(Decimal::create('5.01')->isInteger()); // bool(false) ``` + +`isEven()` and `isOdd()` look at whole numbers of any size. Both are false for a value with decimals: + +```php +var_dump(Decimal::create('4.00')->isEven()); // bool(true) +var_dump(Decimal::create('7')->isOdd()); // bool(true) +var_dump(Decimal::create('2.5')->isEven()); // bool(false) +``` + +> [!TIP] Added in 1.4 +> `isEven()` and `isOdd()`. diff --git a/docs/guide/creating.md b/docs/guide/creating.md index e771455..a1d4ed9 100644 --- a/docs/guide/creating.md +++ b/docs/guide/creating.md @@ -49,10 +49,26 @@ Localized strings such as `'1.018,90'` are rejected. Parse them with `NumberForm A float is converted with PHP's own float to string rules, which round to the `precision` ini setting. That hides small errors but cannot restore digits the float never had. For money and other exact values, read the number as a string from the request or the database. ::: -::: warning Scientific notation from untrusted input -`'1e1000000000'` is twelve characters long and expands to a string of a billion digits. If the value comes from a request, reject exponent notation or check the size of the exponent before you create the object. A limit on the input length does not help. +::: 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. ::: +## Optional input + +`tryCreate()` returns `null` where `create()` would throw for a value that is not numeric, and passes `null` through: + +```php +Decimal::tryCreate('1.50'); // Decimal 1.50 +Decimal::tryCreate('abc'); // null +Decimal::tryCreate(''); // null +Decimal::tryCreate(null); // null +``` + +This fits optional form fields and nullable columns. + +> [!TIP] Added in 1.4 +> `tryCreate()` and the exponent limit. + ## Setting a scale The second argument fixes the number of decimal places: diff --git a/docs/guide/rounding.md b/docs/guide/rounding.md index bba8110..6e394b8 100644 --- a/docs/guide/rounding.md +++ b/docs/guide/rounding.md @@ -22,22 +22,61 @@ The default mode rounds half away from zero, like PHP's `round()`. It works on t ## Rounding modes +Pass a mode as second argument. All of these round at the given scale: + +| Constant | Direction | `2.5` | `3.5` | `-2.5` | `2.1` | +| --- | --- | --- | --- | --- | --- | +| `Decimal::ROUND_HALF_UP` | nearest, ties away from zero (default) | `3` | `4` | `-3` | `2` | +| `Decimal::ROUND_HALF_DOWN` | nearest, ties towards zero | `2` | `3` | `-2` | `2` | +| `Decimal::ROUND_HALF_EVEN` | nearest, ties to the even neighbor | `2` | `4` | `-2` | `2` | +| `Decimal::ROUND_HALF_ODD` | nearest, ties to the odd neighbor | `3` | `3` | `-3` | `2` | +| `Decimal::ROUND_UP` | away from zero | `3` | `4` | `-3` | `3` | +| `Decimal::ROUND_DOWN` | towards zero | `2` | `3` | `-2` | `2` | + +```php +echo Decimal::create('0.125')->round(2, Decimal::ROUND_HALF_EVEN); // 0.12 +echo Decimal::create('12.375')->round(2, Decimal::ROUND_DOWN); // 12.37 +``` + +`ROUND_HALF_EVEN` is the banker's rounding used in accounting to avoid a drift over many values. + +> [!TIP] Added in 1.4 +> `ROUND_HALF_DOWN`, `ROUND_HALF_EVEN`, `ROUND_HALF_ODD`, `ROUND_UP` and `ROUND_DOWN`. + +### Floor and ceil modes + | Constant | Direction | `12.375` | `-12.375` | | --- | --- | --- | --- | -| `Decimal::ROUND_HALF_UP` | nearest, half away from zero, at the given scale | `12.38` at scale 2 | `-12.38` at scale 2 | | `Decimal::ROUND_FLOOR` | whole number towards negative infinity | `12` | `-13` | | `Decimal::ROUND_CEIL` | whole number towards positive infinity | `13` | `-12` | +::: warning Limits in 1.x +With `round()`, `ROUND_FLOOR` and `ROUND_CEIL` always round to a whole number. A scale passed along with them does not move the rounding position. Use [`withScale()`](#withscale) to floor or ceil at a scale. + +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, and `round()` treats any value it does not know as half up. The next major version will reject unknown modes. +::: + +## withScale() + +Returns the value with exactly the given number of decimal places. Missing places are filled with zeros, surplus places are rounded: + ```php -echo $value->round(0, Decimal::ROUND_FLOOR); // 12 -echo $value->round(0, Decimal::ROUND_CEIL); // 13 +echo Decimal::create('1.5')->withScale(3); // 1.500 +echo Decimal::create('7')->withScale(2); // 7.00 +echo Decimal::create('1.255')->withScale(2); // 1.26 ``` -::: warning Limits of the directed modes -`ROUND_FLOOR` and `ROUND_CEIL` always round to a whole number. A scale passed along with them does not move the rounding position. +Every mode is applied at the scale, including floor and ceil: -Any other mode rounds half up. That includes PHP's `PHP_ROUND_HALF_EVEN`, `PHP_ROUND_HALF_ODD` and `PHP_ROUND_HALF_DOWN`, which are not supported. The next major version will apply the scale to all modes and reject unsupported ones. -::: +```php +echo Decimal::create('12.375')->withScale(1, Decimal::ROUND_FLOOR); // 12.3 +echo Decimal::create('-12.375')->withScale(1, Decimal::ROUND_FLOOR); // -12.4 +echo Decimal::create('12.375')->withScale(1, Decimal::ROUND_CEIL); // 12.4 +``` + +Use it before output or storage when a column or an API expects a fixed number of decimals. An unknown mode throws an `InvalidArgumentException`. + +> [!TIP] Added in 1.4 ## floor() and ceil() @@ -62,6 +101,17 @@ echo Decimal::create('12.3')->truncate(5); // 12.3 `truncate()` never adds zeros. Use `round()` when the result needs a fixed scale. -## Negative scale +## Rounding to tens and hundreds + +A negative scale rounds left of the decimal point: + +```php +echo Decimal::create('1250')->round(-2); // 1300 +echo Decimal::create('14.99')->round(-1); // 10 +echo Decimal::create('1250')->round(-2, Decimal::ROUND_HALF_EVEN); // 1200 +``` + +This is not available for `ROUND_FLOOR`, `ROUND_CEIL`, `withScale()` and `truncate()`, which throw an `InvalidArgumentException` for a scale below zero. -Rounding to tens or hundreds is not supported. `round()` and `truncate()` throw an `InvalidArgumentException` for a scale below zero. +> [!TIP] Added in 1.4 +> `round()` threw for every negative scale before. diff --git a/docs/guide/scale.md b/docs/guide/scale.md index 2dac922..de9ec24 100644 --- a/docs/guide/scale.md +++ b/docs/guide/scale.md @@ -47,7 +47,18 @@ A cut-off quotient does not multiply back to the original: echo Decimal::create('100')->divide('3', 2)->multiply(3); // 99.99 ``` -When splitting an amount, calculate the last share as the remainder instead of dividing again. +To split an amount without losing a cent, use [`split()` or `allocate()`](./arithmetic#splitting-an-amount). + +## Setting a fixed scale + +`withScale()` pads or rounds to an exact number of decimal places: + +```php +echo Decimal::create('7')->withScale(2); // 7.00 +echo Decimal::create('1.255')->withScale(2); // 1.26 +``` + +See [Rounding](./rounding#withscale) for the rounding modes it accepts. ## Removing trailing zeros diff --git a/docs/reference/api.md b/docs/reference/api.md index ad8f898..75b05ff 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -10,6 +10,7 @@ Class `PhpCollective\DecimalObject\Decimal`, implementing `JsonSerializable` and | --- | --- | | `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. | +| `Decimal::tryCreate($value, ?int $scale = null): ?static` | Returns `null` for `null` and for input that is not numeric. | See [Creating decimals](../guide/creating). @@ -24,16 +25,30 @@ See [Creating decimals](../guide/creating). | `mod($value, ?int $scale = null): static` | Remainder. `$value` is a `Decimal`, string or integer. | scale of the value | | `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 | +| `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 | +| 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::min(iterable $values): static` | Smallest value | +| `Decimal::max(iterable $values): static` | Largest value | + +| Method | Description | +| --- | --- | +| `allocate(array $ratios): array` | Shares by ratio that sum to the value exactly, keys preserved | +| `split(int $parts): array` | Equal shares that sum to the value exactly | + See [Arithmetic](../guide/arithmetic). ## Rounding | Method | Description | | --- | --- | -| `round(int $scale = 0, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds half up to `$scale` decimal places, or to a whole number with `ROUND_FLOOR` and `ROUND_CEIL`. | +| `round(int $scale = 0, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds to `$scale` decimal places, or to a whole number with `ROUND_FLOOR` and `ROUND_CEIL`. 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, in any mode. | | `floor(): static` | Nearest integer towards negative infinity. | | `ceil(): static` | Nearest integer towards positive infinity. | | `truncate(int $scale = 0): static` | Cuts off digits beyond `$scale`. | @@ -44,6 +59,12 @@ See [Arithmetic](../guide/arithmetic). | `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 | See [Rounding](../guide/rounding). @@ -57,6 +78,8 @@ See [Rounding](../guide/rounding). | `greaterThanOrEquals($value): bool` | | | `lessThan($value): bool` | | | `lessThanOrEquals($value): bool` | | +| `between($min, $max, bool $inclusive = true): bool` | Within the range | +| `clamp($min, $max): static` | The value, or the nearest bound when outside the range | See [Comparison and checks](../guide/comparison). @@ -68,6 +91,8 @@ See [Comparison and checks](../guide/comparison). | `isPositive(): bool` | Greater than zero | | `isNegative(): bool` | Less than zero | | `isInteger(): bool` | No decimals other than zeros | +| `isEven(): bool` | Whole number divisible by two | +| `isOdd(): bool` | Whole number not divisible by two | | `isBigInteger(): bool` | Integral part does not fit a PHP integer | | `isBigDecimal(): bool` | `toFloat()` would refuse the value | | `sign(): int` | `-1`, `0` or `1` | @@ -90,7 +115,10 @@ See [Conversion](../guide/conversion). | Situation | Exception | | --- | --- | | Input is not numeric, or a float is `INF` or `NAN` | `InvalidArgumentException` | -| Negative scale in the constructor, `round()` or `truncate()` | `InvalidArgumentException` | +| Exponent beyond `Decimal::MAX_EXPONENT` | `InvalidArgumentException` | +| Negative scale in the constructor, `withScale()`, `truncate()` or `round()` with `ROUND_FLOOR` / `ROUND_CEIL` | `InvalidArgumentException` | +| Unknown mode in `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()` or `mod()` by zero | `DivisionByZeroError` | | `toInt()` or `toFloat()` on a value that does not fit | `TypeError` | diff --git a/docs/reference/upgrading.md b/docs/reference/upgrading.md index 1f45b94..76eadf3 100644 --- a/docs/reference/upgrading.md +++ b/docs/reference/upgrading.md @@ -1,12 +1,26 @@ # Upgrading -## From 1.3 +## From 1.3 to 1.4 PHP 8.2 is now required. Composer keeps PHP 8.1 projects on 1.3. +### New + +- Rounding modes `ROUND_HALF_DOWN`, `ROUND_HALF_EVEN`, `ROUND_HALF_ODD`, `ROUND_UP` and `ROUND_DOWN`, see [Rounding](../guide/rounding#rounding-modes) +- `withScale()` to pad or round to a fixed scale, and `round()` with a negative scale +- `Decimal::sum()`, `average()`, `min()` and `max()` +- `split()` and `allocate()` to divide an amount without a remainder getting lost +- `shift()`, `between()`, `clamp()`, `isEven()`, `isOdd()` and `Decimal::tryCreate()` + +### Exponent limit + +Scientific notation is limited to exponents between -10000 and 10000. Larger ones throw an `InvalidArgumentException`. + +### Fixes + The release fixes wrong results and does not change the result of a call that was correct before. Check the following if your code worked around one of the bugs. -### Parsing +#### Parsing - Scientific notation with more than one digit before the decimal point and a negative exponent was parsed wrong, for example `'12.5e-1'` as `0.125`. Stored values that came from such input need correcting. - `'1e+5'` and large floats such as `1e25` are accepted. They threw before. @@ -14,22 +28,21 @@ The release fixes wrong results and does not change the result of a call that wa - `INF` and `NAN` throw an `InvalidArgumentException`. They were stored as the strings `INF` and `NAN`. - A negative scale throws an `InvalidArgumentException`. -### Negative zero +#### Negative zero Zero has no sign. `'-0.0'` becomes `0.0`, `isNegative()` is false for it, and `negate()`, `truncate()` and `trim()` no longer return `-0`. -### Rounding +#### Rounding `floor()` and `ceil()` no longer move whole numbers written with decimals. `Decimal::create('-1.0')->floor()` returned `-2`, and `Decimal::create('1.0')->ceil()` returned `2`. -`round()` with a negative scale throws an `InvalidArgumentException`. It used to surface a `ValueError` from bcmath. -### Conversion +#### Conversion - `toScientific()` returned `.e-1` for zero, lost digits beyond the float precision for large integers, and returned `1.250e2` for `120.50`. - `toInt()` accepts `PHP_INT_MIN`. -### Operands +#### Operands `mod()` and `pow()` accept operands in scientific notation. diff --git a/src/Decimal.php b/src/Decimal.php index f92677b..5ffd5b4 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -39,6 +39,44 @@ class Decimal implements JsonSerializable, Stringable */ public const ROUND_FLOOR = 8; + /** + * @var int + */ + public const ROUND_HALF_DOWN = 9; + + /** + * @var int + */ + public const ROUND_HALF_EVEN = 10; + + /** + * @var int + */ + public const ROUND_HALF_ODD = 11; + + /** + * Towards zero. + * + * @var int + */ + public const ROUND_DOWN = 12; + + /** + * Away from zero. + * + * @var int + */ + public const ROUND_UP = 13; + + /** + * Largest exponent accepted in scientific notation, in either direction. + * + * `1e1000000000` would otherwise expand to a string of a billion digits. + * + * @var int + */ + public const MAX_EXPONENT = 10000; + /** * Integral part of this decimal number. * @@ -157,6 +195,109 @@ public static function create(object|string|float|int $value, ?int $scale = null return new static($value, $scale, $strict); } + /** + * Like create(), but returns null for null and for values that are not numeric. + * + * @param object|string|float|int|null $value + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. + * + * @return static|null + */ + public static function tryCreate(object|string|float|int|null $value, ?int $scale = null): ?static + { + if ($value === null) { + return null; + } + + try { + return static::create($value, $scale); + } catch (InvalidArgumentException) { + return null; + } + } + + /** + * @param iterable $values + * + * @return static + */ + public static function sum(iterable $values): static + { + $sum = new static('0'); + foreach ($values as $value) { + $sum = $sum->add(static::create($value)); + } + + return $sum; + } + + /** + * @param iterable $values + * @param int $scale Decimal places in the result + * + * @throws \InvalidArgumentException If there are no values. + * + * @return static + */ + public static function average(iterable $values, int $scale): static + { + $sum = new static('0'); + $count = 0; + foreach ($values as $value) { + $sum = $sum->add(static::create($value)); + $count++; + } + if ($count === 0) { + throw new InvalidArgumentException('Cannot average an empty list.'); + } + + return $sum->divide($count, $scale); + } + + /** + * @param iterable $values + * + * @return static + */ + public static function min(iterable $values): static + { + return static::extreme($values, -1); + } + + /** + * @param iterable $values + * + * @return static + */ + public static function max(iterable $values): static + { + return static::extreme($values, 1); + } + + /** + * @param iterable $values + * @param int $direction -1 for the smallest, 1 for the largest value + * + * @throws \InvalidArgumentException + * + * @return static + */ + protected static function extreme(iterable $values, int $direction): static + { + $result = null; + foreach ($values as $value) { + $decimal = static::create($value); + if ($result === null || $decimal->compareTo($result) === $direction) { + $result = $decimal; + } + } + if ($result === null) { + throw new InvalidArgumentException('Cannot pick from an empty list.'); + } + + return $result; + } + /** * Equality * @@ -386,6 +527,69 @@ public function isPositive(): bool return !$this->negative && !$this->isZero(); } + /** + * Returns if a whole number divisible by two. Always false for values with decimals. + * + * @return bool + */ + public function isEven(): bool + { + return $this->isInteger() && (int)substr($this->integralPart, -1) % 2 === 0; + } + + /** + * Returns if a whole number not divisible by two. Always false for values with decimals. + * + * @return bool + */ + public function isOdd(): bool + { + return $this->isInteger() && (int)substr($this->integralPart, -1) % 2 === 1; + } + + /** + * @param self|string|float|int $min + * @param self|string|float|int $max + * @param bool $inclusive If the bounds themselves count as in between. + * + * @return bool + */ + public function between(self|string|float|int $min, self|string|float|int $max, bool $inclusive = true): bool + { + if ($inclusive) { + return $this->compareTo($min) >= 0 && $this->compareTo($max) <= 0; + } + + return $this->compareTo($min) > 0 && $this->compareTo($max) < 0; + } + + /** + * Returns this value limited to the given range. + * + * @param self|string|float|int $min + * @param self|string|float|int $max + * + * @throws \InvalidArgumentException If $min is greater than $max. + * + * @return static + */ + public function clamp(self|string|float|int $min, self|string|float|int $max): static + { + $min = static::create($min); + $max = static::create($max); + if ($min->greaterThan($max)) { + throw new InvalidArgumentException('Min must not be greater than max.'); + } + if ($this->lessThan($min)) { + return $min; + } + if ($this->greaterThan($max)) { + return $max; + } + + return clone $this; + } + /** * Multiply this Decimal by $value and return the product as a new Decimal. * @@ -475,44 +679,212 @@ public function mod(self|string|int $value, ?int $scale = null): static } /** - * @param int $scale Decimal places in the result (only used with mode "half up") - * @param int $roundMode ROUND_HALF_UP, ROUND_CEIL or ROUND_FLOOR. Anything else rounds half up. + * ROUND_FLOOR and ROUND_CEIL always round to a whole number, use withScale() to round + * in those directions at a scale. A mode that is not a ROUND_* constant rounds half up. + * + * @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 * * @return static */ public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP): static + { + if (in_array($roundMode, [static::ROUND_FLOOR, static::ROUND_CEIL], true)) { + if ($scale < 0) { + throw new InvalidArgumentException('Scale must be >= 0.'); + } + + return new static($this->roundToScale(0, $roundMode), $scale); + } + + if (!in_array($roundMode, static::roundModes(), true)) { + $roundMode = static::ROUND_HALF_UP; + } + + return new static($this->roundToScale($scale, $roundMode), max($scale, 0)); + } + + /** + * Returns this value with exactly the given number of decimal places. + * + * Missing places are filled with zeros, surplus places are rounded. Unlike round(), + * every mode including ROUND_FLOOR and ROUND_CEIL is applied at the scale. + * + * @param int $scale Decimal places in the result + * @param int $roundMode One of the ROUND_* constants + * + * @throws \InvalidArgumentException + * + * @return static + */ + public function withScale(int $scale, 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 . '`.'); + } + if ($scale >= strlen($this->fractionalPart)) { + return $this->copy(null, str_pad($this->fractionalPart, $scale, '0')); + } - // Floor and ceil always round to a whole number in 1.x, the scale only labels the result. - $directed = in_array($roundMode, [static::ROUND_FLOOR, static::ROUND_CEIL], true); - $places = $directed ? 0 : $scale; + return new static($this->roundToScale($scale, $roundMode), $scale); + } - $value = (string)$this; - $truncated = bcadd($value, '0', $places); - if (bccomp($value, $truncated, $this->scale()) === 0) { - return new static($truncated, $scale); + /** + * Moves the decimal point, which multiplies by a power of ten without losing digits. + * + * @param int $places Positive to the right (larger value), negative to the left. + * + * @return static + */ + public function shift(int $places): static + { + $digits = $this->integralPart . $this->fractionalPart; + $point = strlen($this->integralPart) + $places; + if ($point <= 0) { + $digits = str_repeat('0', 1 - $point) . $digits; + $point = 1; } + $digits = str_pad($digits, $point, '0'); - switch ($roundMode) { - case static::ROUND_FLOOR: - $result = $this->isNegative() ? bcsub($truncated, '1', 0) : $truncated; + $value = substr($digits, 0, $point); + if (strlen($digits) > $point) { + $value .= static::RADIX_MARK . substr($digits, $point); + } - break; - case static::ROUND_CEIL: - $result = $this->isNegative() ? $truncated : bcadd($truncated, '1', 0); + return new static(($this->negative ? '-' : '') . $value); + } + + /** + * @return array + */ + protected static function roundModes(): array + { + return [ + static::ROUND_HALF_UP, + static::ROUND_HALF_DOWN, + static::ROUND_HALF_EVEN, + static::ROUND_HALF_ODD, + static::ROUND_UP, + static::ROUND_DOWN, + static::ROUND_CEIL, + static::ROUND_FLOOR, + ]; + } + + /** + * @param int $scale + * @param int $roundMode + * + * @return string + */ + protected function roundToScale(int $scale, int $roundMode): string + { + $shifted = $this->shift($scale); + $integralPart = $shifted->integralPart; + $fractionalPart = $shifted->fractionalPart; + + $exact = trim($fractionalPart, '0') === ''; + $half = $exact ? -1 : bccomp('0.' . $fractionalPart, '0.5', strlen($fractionalPart)); + $odd = (int)substr($integralPart, -1) % 2 === 1; + + $awayFromZero = match ($roundMode) { + static::ROUND_FLOOR => !$exact && $this->negative, + static::ROUND_CEIL => !$exact && !$this->negative, + static::ROUND_UP => !$exact, + static::ROUND_DOWN => false, + static::ROUND_HALF_DOWN => $half > 0, + static::ROUND_HALF_EVEN => $half > 0 || ($half === 0 && $odd), + static::ROUND_HALF_ODD => $half > 0 || ($half === 0 && !$odd), + default => $half >= 0, + }; + if ($awayFromZero) { + $integralPart = bcadd($integralPart, '1', 0); + } + + return (string)(new static(($this->negative ? '-' : '') . $integralPart))->shift(-$scale); + } + + /** + * Splits this value by the given ratios without losing or inventing anything. + * + * The shares add up to this value exactly. What cannot be divided evenly at the scale + * of this value goes to the first shares, one smallest unit each. + * + * @param array $ratios Non negative weights, keys are preserved. + * + * @throws \InvalidArgumentException + * + * @return array + */ + public function allocate(array $ratios): array + { + $weights = []; + $ratioScale = 0; + foreach ($ratios as $key => $ratio) { + $weight = static::create($ratio); + if ($weight->isNegative()) { + throw new InvalidArgumentException('Ratios must not be negative.'); + } + $weights[$key] = (string)$weight; + $ratioScale = max($ratioScale, $weight->scale()); + } + + $total = '0'; + foreach ($weights as $weight) { + $total = bcadd($total, $weight, $ratioScale); + } + if (bccomp($total, '0', $ratioScale) !== 1) { + throw new InvalidArgumentException('Ratios must add up to more than zero.'); + } + $scale = $this->scale(); + $units = $this->absolute()->shift($scale)->integralPart; + + $shares = []; + $remainder = $units; + foreach ($weights as $key => $weight) { + $shares[$key] = bcdiv(bcmul($units, $weight, $ratioScale), $total, 0); + $remainder = bcsub($remainder, $shares[$key], 0); + } + foreach ($weights as $key => $weight) { + if ($remainder === '0') { break; - default: - $e = bcpow('10', (string)($scale + 1)); - $result = bcdiv(bcadd(bcmul($value, $e, 0), $this->isNegative() ? '-5' : '5'), $e, $scale); + } + if (bccomp($weight, '0', $ratioScale) === 1) { + $shares[$key] = bcadd($shares[$key], '1', 0); + $remainder = bcsub($remainder, '1', 0); + } + } + + $sign = $this->negative ? '-' : ''; + foreach ($shares as $key => $share) { + $shares[$key] = (new static($sign . $share))->shift(-$scale); } - return new static($result, $scale); + return $shares; + } + + /** + * Splits this value into equal parts, see allocate(). + * + * @param int $parts + * + * @throws \InvalidArgumentException + * + * @return array + */ + public function split(int $parts): array + { + if ($parts < 1) { + throw new InvalidArgumentException('Parts must be >= 1.'); + } + + return $this->allocate(array_fill(0, $parts, 1)); } /** @@ -815,6 +1187,9 @@ protected function fromScientific(string $value, ?int $scale): void // A mantissa of `1.0` is read as `1` in 1.x. $fractionalPart = $matches[3] === '0' ? '' : $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 . '.'); + } if ($exp < 0) { $integralPart = str_pad($integralPart, -$exp + 1, '0', STR_PAD_LEFT); diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index aa5ad49..df1a9e8 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -9,6 +9,7 @@ namespace PhpCollective\DecimalObject\Test; +use ArrayIterator; use DivisionByZeroError; use InvalidArgumentException; use PhpCollective\DecimalObject\Decimal; @@ -1190,16 +1191,6 @@ public static function roundModeProvider(): array ]; } - /** - * @return void - */ - public function testRoundNegativeScale(): void - { - $this->expectException(InvalidArgumentException::class); - - (new Decimal('15'))->round(-1); - } - /** * @param string $value * @param string $expected @@ -1272,4 +1263,447 @@ public function testPowOperands(): void $this->assertSame('0.50', (string)$decimal->pow(-1, 2)); $this->assertSame('2.25', (string)(new Decimal('1.50'))->pow(2)); } + + /** + * @param string $value + * @param int $scale + * @param int $mode + * @param string $expected + * + * @return void + */ + #[DataProvider('roundExtraModeProvider')] + public function testRoundExtraModes(string $value, int $scale, int $mode, string $expected): void + { + $this->assertSame($expected, (string)(new Decimal($value))->round($scale, $mode)); + } + + /** + * @return array + */ + public static function roundExtraModeProvider(): array + { + return [ + 'half down tie' => ['1.25', 1, Decimal::ROUND_HALF_DOWN, '1.2'], + 'half down above tie' => ['1.251', 1, Decimal::ROUND_HALF_DOWN, '1.3'], + 'half down negative tie' => ['-1.25', 1, Decimal::ROUND_HALF_DOWN, '-1.2'], + 'half even tie to even' => ['1.25', 1, Decimal::ROUND_HALF_EVEN, '1.2'], + 'half even tie to even up' => ['1.35', 1, Decimal::ROUND_HALF_EVEN, '1.4'], + 'half even negative' => ['-2.5', 0, Decimal::ROUND_HALF_EVEN, '-2'], + 'half even carry' => ['9.5', 0, Decimal::ROUND_HALF_EVEN, '10'], + 'half even above tie' => ['2.5000001', 0, Decimal::ROUND_HALF_EVEN, '3'], + 'half odd tie' => ['2.5', 0, Decimal::ROUND_HALF_ODD, '3'], + 'half odd tie stays' => ['1.5', 0, Decimal::ROUND_HALF_ODD, '1'], + 'down' => ['1.29', 1, Decimal::ROUND_DOWN, '1.2'], + 'down negative' => ['-1.29', 1, Decimal::ROUND_DOWN, '-1.2'], + 'up' => ['1.21', 1, Decimal::ROUND_UP, '1.3'], + 'up negative' => ['-1.21', 1, Decimal::ROUND_UP, '-1.3'], + 'up exact' => ['1.20', 1, Decimal::ROUND_UP, '1.2'], + 'pads' => ['1.5', 3, Decimal::ROUND_HALF_EVEN, '1.500'], + 'to zero keeps scale' => ['-0.004', 2, Decimal::ROUND_HALF_EVEN, '0.00'], + 'tens' => ['15', -1, Decimal::ROUND_HALF_UP, '20'], + 'tens down' => ['14.99', -1, Decimal::ROUND_HALF_UP, '10'], + 'hundreds negative' => ['-1250', -2, Decimal::ROUND_HALF_UP, '-1300'], + 'hundreds half even' => ['1250', -2, Decimal::ROUND_HALF_EVEN, '1200'], + 'tens below' => ['4', -1, Decimal::ROUND_HALF_UP, '0'], + 'php half even still rounds half up' => ['2.5', 0, PHP_ROUND_HALF_EVEN, '3'], + ]; + } + + /** + * @return void + */ + public function testRoundNegativeScaleDirected(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('15'))->round(-1, Decimal::ROUND_FLOOR); + } + + /** + * @param string $value + * @param int $scale + * @param int $mode + * @param string $expected + * + * @return void + */ + #[DataProvider('withScaleProvider')] + public function testWithScale(string $value, int $scale, int $mode, string $expected): void + { + $result = (new Decimal($value))->withScale($scale, $mode); + + $this->assertSame($expected, (string)$result); + $this->assertSame($scale, $result->scale()); + } + + /** + * @return array + */ + public static function withScaleProvider(): array + { + return [ + 'pad' => ['1.5', 3, Decimal::ROUND_HALF_UP, '1.500'], + 'pad integer' => ['7', 2, Decimal::ROUND_HALF_UP, '7.00'], + 'same' => ['1.50', 2, Decimal::ROUND_HALF_UP, '1.50'], + 'round' => ['1.255', 2, Decimal::ROUND_HALF_UP, '1.26'], + 'to integer' => ['1.5', 0, Decimal::ROUND_HALF_UP, '2'], + 'floor at scale' => ['1.25', 1, Decimal::ROUND_FLOOR, '1.2'], + 'floor negative at scale' => ['-1.21', 1, Decimal::ROUND_FLOOR, '-1.3'], + 'ceil at scale' => ['1.21', 1, Decimal::ROUND_CEIL, '1.3'], + 'ceil negative at scale' => ['-1.29', 1, Decimal::ROUND_CEIL, '-1.2'], + 'ceil small negative' => ['-0.001', 2, Decimal::ROUND_CEIL, '0.00'], + 'half even' => ['0.125', 2, Decimal::ROUND_HALF_EVEN, '0.12'], + ]; + } + + /** + * @return void + */ + public function testWithScaleDeclaredScale(): void + { + $result = Decimal::create('0.1', 4)->withScale(4); + + $this->assertSame('0.1000', (string)$result); + } + + /** + * @return void + */ + public function testWithScaleNegative(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('1.5'))->withScale(-1); + } + + /** + * @return void + */ + public function testWithScaleInvalidMode(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('1.5'))->withScale(2, PHP_ROUND_HALF_EVEN); + } + + /** + * @param string $value + * @param int $places + * @param string $expected + * + * @return void + */ + #[DataProvider('shiftProvider')] + public function testShift(string $value, int $places, string $expected): void + { + $this->assertSame($expected, (string)(new Decimal($value))->shift($places)); + } + + /** + * @return array + */ + public static function shiftProvider(): array + { + return [ + 'none' => ['12.34', 0, '12.34'], + 'right' => ['12.34', 1, '123.4'], + 'right all' => ['12.34', 2, '1234'], + 'right beyond' => ['12.34', 4, '123400'], + 'left' => ['12.34', -1, '1.234'], + 'left all' => ['12.34', -2, '0.1234'], + 'left beyond' => ['12.34', -4, '0.001234'], + 'negative' => ['-0.05', 2, '-5'], + 'zero' => ['0', 3, '0'], + 'zero left' => ['0', -2, '0.00'], + 'integer left keeps zeros' => ['100', -2, '1.00'], + ]; + } + + /** + * @return void + */ + public function testExponentLimit(): void + { + $this->assertSame(Decimal::MAX_EXPONENT + 1, strlen((string)new Decimal('1e' . Decimal::MAX_EXPONENT))); + $this->assertSame(Decimal::MAX_EXPONENT, (new Decimal('1e-' . Decimal::MAX_EXPONENT))->scale()); + } + + /** + * @param string $value + * + * @return void + */ + #[DataProvider('exponentLimitProvider')] + public function testExponentLimitExceeded(string $value): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Exponent must be between'); + + new Decimal($value); + } + + /** + * @return array + */ + public static function exponentLimitProvider(): array + { + return [ + 'positive' => ['1e' . (Decimal::MAX_EXPONENT + 1)], + 'negative' => ['1e-' . (Decimal::MAX_EXPONENT + 1)], + 'huge' => ['1e1000000000'], + 'beyond int' => ['1e99999999999999999999'], + ]; + } + + /** + * @return void + */ + public function testTryCreate(): void + { + $this->assertSame('1.50', (string)Decimal::tryCreate('1.50')); + $this->assertSame('1.5', (string)Decimal::tryCreate('1.56', 1)); + $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)); + } + + /** + * @return void + */ + public function testSum(): void + { + $this->assertSame('6.75', (string)Decimal::sum(['1.5', 2, Decimal::create('3.25')])); + $this->assertSame('0', (string)Decimal::sum([])); + $this->assertSame('0.3', (string)Decimal::sum(new ArrayIterator(['0.1', '0.2']))); + } + + /** + * @return 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)); + } + + /** + * @return void + */ + public function testAverageEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + + Decimal::average([], 2); + } + + /** + * @return void + */ + public function testMinMax(): void + { + $values = ['2.5', '-1', '2.50', 7, '-1.0']; + + $this->assertSame('-1', (string)Decimal::min($values)); + $this->assertSame('7', (string)Decimal::max($values)); + $this->assertSame('2.5', (string)Decimal::max(['2.5', '2.50'])); + } + + /** + * @return void + */ + public function testMinEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + + Decimal::min([]); + } + + /** + * @return void + */ + public function testMaxEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + + Decimal::max([]); + } + + /** + * @param string $value + * @param bool $even + * @param bool $odd + * + * @return void + */ + #[DataProvider('parityProvider')] + public function testParity(string $value, bool $even, bool $odd): void + { + $decimal = new Decimal($value); + + $this->assertSame($even, $decimal->isEven()); + $this->assertSame($odd, $decimal->isOdd()); + } + + /** + * @return array + */ + public static function parityProvider(): array + { + return [ + 'zero' => ['0', true, false], + 'even' => ['12', true, false], + 'odd' => ['-7', false, true], + 'even with zero decimals' => ['4.00', true, false], + 'odd with zero decimals' => ['3.0', false, true], + 'fraction' => ['2.5', false, false], + 'large' => ['123456789012345678901234567891', false, true], + ]; + } + + /** + * @return void + */ + public function testBetween(): void + { + $decimal = new Decimal('5.0'); + + $this->assertTrue($decimal->between(1, 10)); + $this->assertTrue($decimal->between('5', '5')); + $this->assertTrue($decimal->between('5', 10)); + $this->assertFalse($decimal->between('5', 10, false)); + $this->assertFalse($decimal->between(1, '5.00', false)); + $this->assertTrue($decimal->between('4.99', '5.01', false)); + $this->assertFalse($decimal->between(6, 10)); + } + + /** + * @return void + */ + public function testClamp(): void + { + $this->assertSame('5', (string)(new Decimal('5'))->clamp(1, 10)); + $this->assertSame('1', (string)(new Decimal('-3.5'))->clamp(1, 10)); + $this->assertSame('10.00', (string)(new Decimal('12'))->clamp('1', '10.00')); + } + + /** + * @return void + */ + public function testClampInvalidRange(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('5'))->clamp(10, 1); + } + + /** + * @param string $value + * @param array $ratios + * @param array $expected + * + * @return void + */ + #[DataProvider('allocateProvider')] + public function testAllocate(string $value, array $ratios, array $expected): void + { + $decimal = new Decimal($value); + $shares = $decimal->allocate($ratios); + + $this->assertSame($expected, array_map('strval', $shares)); + $this->assertSame((string)$decimal, (string)Decimal::sum($shares)->withScale($decimal->scale())); + } + + /** + * @return array + */ + public static function allocateProvider(): array + { + return [ + 'even' => ['100.00', [1, 1], ['50.00', '50.00']], + 'thirds' => ['100.00', [1, 1, 1], ['33.34', '33.33', '33.33']], + 'weighted' => ['0.05', [70, 30], ['0.04', '0.01']], + 'decimal ratios' => ['10.00', ['0.7', '0.3'], ['7.00', '3.00']], + 'integer' => ['10', [1, 1, 1], ['4', '3', '3']], + 'negative' => ['-100.00', [1, 1, 1], ['-33.34', '-33.33', '-33.33']], + 'zero ratio gets nothing' => ['0.01', [0, 1, 1], ['0.00', '0.01', '0.00']], + 'keys' => ['1.00', ['a' => 1, 'b' => 3], ['a' => '0.25', 'b' => '0.75']], + 'zero' => ['0.00', [1, 2], ['0.00', '0.00']], + ]; + } + + /** + * @param array $ratios + * + * @return void + */ + #[DataProvider('allocateInvalidProvider')] + public function testAllocateInvalid(array $ratios): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('1.00'))->allocate($ratios); + } + + /** + * @return array + */ + public static function allocateInvalidProvider(): array + { + return [ + 'empty' => [[]], + 'all zero' => [[0, 0]], + 'negative' => [[1, -1]], + 'not numeric' => [['a']], + ]; + } + + /** + * @return void + */ + public function testSplit(): void + { + $shares = (new Decimal('10.00'))->split(3); + + $this->assertSame(['3.34', '3.33', '3.33'], array_map('strval', $shares)); + $this->assertSame(['5'], array_map('strval', (new Decimal('5'))->split(1))); + } + + /** + * @return void + */ + public function testSplitInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('10.00'))->split(0); + } + + /** + * A global bcmath scale set by the application must not leak into results. + * + * @return void + */ + public function testGlobalBcScale(): void + { + $previous = bcscale(2); + try { + $this->assertSame('2', (string)(new Decimal('1.5'))->round()); + $this->assertSame('1.3', (string)(new Decimal('1.25'))->round(1)); + $this->assertSame('2', (string)(new Decimal('1.5'))->withScale(0)); + $this->assertSame('20', (string)(new Decimal('15'))->round(-1)); + $this->assertSame('2', (string)(new Decimal('1.5'))->ceil()); + $this->assertSame(['0.50', '0.50'], array_map('strval', (new Decimal('1.00'))->split(2))); + $this->assertSame(['3.34', '3.33', '3.33'], array_map('strval', (new Decimal('10.00'))->split(3))); + $this->assertSame('3', (string)Decimal::sum([1, 2])); + $this->assertSame('1000', (string)new Decimal('1e3')); + $this->assertSame('1.e3', (new Decimal('1000'))->toScientific()); + $this->assertSame(PHP_INT_MAX, (new Decimal((string)PHP_INT_MAX))->toInt()); + $this->assertTrue((new Decimal('4'))->isEven()); + } finally { + bcscale($previous); + } + } }