Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/guide/arithmetic.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions docs/guide/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
:::
Expand All @@ -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()`.
20 changes: 18 additions & 2 deletions docs/guide/creating.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
68 changes: 59 additions & 9 deletions docs/guide/rounding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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.
13 changes: 12 additions & 1 deletion docs/guide/scale.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 30 additions & 2 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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`. |
Expand All @@ -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).

Expand All @@ -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).

Expand All @@ -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` |
Expand All @@ -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` |
Expand Down
27 changes: 20 additions & 7 deletions docs/reference/upgrading.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,48 @@
# 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.
- Leading zeros are removed in all forms: `'0010'` is `10`, `'-007'` is `-7`.
- `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.

Expand Down
Loading
Loading