├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ci.yml ├── .license ├── LICENSE ├── README.md ├── composer.json ├── docs └── README.md ├── phpcs.xml ├── phpstan.neon ├── src └── Decimal.php └── tests └── DecimalTest.php /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR Description 2 | Add a meaningful description here that will let us know what you want to fix with this PR or what functionality you want to add. 3 | 4 | ## Steps before you submit a PR 5 | - Please add tests for the code you add if it's possible. 6 | - Please check out our contribution guide: https://docs.spryker.com/docs/dg/dev/code-contribution-guide.html 7 | - Add a `contribution-license-agreement.txt` file with the following content: 8 | `I hereby agree to Spryker\'s Contribution License Agreement in https://github.com/spryker/decimal-object/blob/HASH_OF_COMMIT_YOU_ARE_BASING_YOUR_BRANCH_FROM_MASTER_BRANCH/CONTRIBUTING.md.` 9 | 10 | This is a mandatory step to make sure you are aware of the license agreement and agree to it. `HASH_OF_COMMIT_YOU_ARE_BASING_YOUR_BRANCH_FROM_MASTER_BRANCH` is a hash of the commit you are basing your branch from the master branch. You can take it from commits list of master branch before you submit a PR. 11 | 12 | ## Checklist 13 | - [x] I agree with the Code Contribution License Agreement in CONTRIBUTING.md 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | workflow_dispatch: 9 | 10 | jobs: 11 | testsuite: 12 | runs-on: ubuntu-20.04 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | php-version: ['8.0', '8.2'] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Setup PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: ${{ matrix.php-version }} 25 | extensions: mbstring, intl, bcmath 26 | coverage: pcov 27 | 28 | - name: Composer install 29 | run: | 30 | composer --version 31 | composer install 32 | 33 | - name: Run PHPUnit 34 | run: | 35 | if [[ ${{ matrix.php-version }} == '8.0' ]]; then 36 | vendor/bin/phpunit --coverage-clover=coverage.xml 37 | else 38 | vendor/bin/phpunit 39 | fi 40 | 41 | - name: Code Coverage Report 42 | if: success() && matrix.php-version == '8.0' 43 | uses: codecov/codecov-action@v1 44 | 45 | validation: 46 | name: Coding Standard & Static Analysis 47 | runs-on: ubuntu-20.04 48 | 49 | steps: 50 | - uses: actions/checkout@v3 51 | 52 | - name: Setup PHP 53 | uses: shivammathur/setup-php@v2 54 | with: 55 | php-version: '8.1' 56 | extensions: mbstring, intl, bcmath 57 | coverage: none 58 | 59 | - name: Composer Install 60 | run: composer install 61 | 62 | - name: Run phpstan 63 | run: vendor/bin/phpstan analyse --error-format=github 64 | 65 | - name: Run phpcs 66 | run: composer cs-check 67 | -------------------------------------------------------------------------------- /.license: -------------------------------------------------------------------------------- 1 | /** 2 | * MIT License 3 | * For full license information, please view the LICENSE file that was distributed with this source code. 4 | */ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present Spryker Systems GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Decimal Object 2 | 3 | [![Build Status](https://github.com/spryker/decimal-object/workflows/CI/badge.svg?branch=master)](https://github.com/spryker/decimal-object/actions?query=workflow%3ACI+branch%3Amaster) 4 | [![codecov](https://codecov.io/gh/spryker/decimal-object/branch/master/graph/badge.svg?token=L1thFB9nOG)](https://codecov.io/gh/spryker/decimal-object) 5 | [![Latest Stable Version](https://poser.pugx.org/spryker/decimal-object/v/stable.svg)](https://packagist.org/packages/spryker/decimal-object) 6 | [![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.0-8892BF.svg)](https://php.net/) 7 | [![PHPStan](https://img.shields.io/badge/PHPStan-level%208-brightgreen.svg?style=flat)](https://phpstan.org/) 8 | [![License](https://poser.pugx.org/spryker/decimal-object/license)](https://packagist.org/packages/spryker/decimal-object) 9 | 10 | Decimal value object for PHP. 11 | 12 | ## Background 13 | When working with monetary values, normal data types like int or float are not suitable for exact arithmetic. 14 | Try out the following in PHP: 15 | ```php 16 | var_dump(0.1 + 0.2); // float(0.3) 17 | var_dump(0.1 + 0.2 - 0.3); // float(5.5511151231258E-17) 18 | ``` 19 | 20 | Handling them as string is a workaround, but as value object you can more easily encapsulate some of the logic. 21 | 22 | ### Alternatives 23 | Solutions like https://php-decimal.io require a PHP extension (would make it faster, but also more difficult for some 24 | servers to be available). For details see the [wiki](https://github.com/spryker/decimal-object/wiki). 25 | 26 | ## Features 27 | 28 | - Super strict on precision/scale. Does not lose significant digits on its own. You need to `trim()` for this manually. 29 | - Speaking API (no le, gt methods). 30 | - Basic math operations and checks supported. 31 | - Immutability. 32 | - Handle very large and very small numbers. 33 | 34 | ## Installation 35 | 36 | ### Requirements 37 | 38 | - `bcmath` PHP extension enabled 39 | 40 | ### Composer (preferred) 41 | ``` 42 | composer require spryker/decimal-object 43 | ``` 44 | 45 | ## Usage 46 | 47 | See [Documentation](/docs) for more details. 48 | 49 | ### Implementations 50 | The following libraries are using the `Decimal` value object: 51 | 52 | - [dereuromark/cakephp-decimal](https://github.com/dereuromark/cakephp-decimal) as decimal type replacement for CakePHP ORM. 53 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spryker/decimal-object", 3 | "description": "PHP decimal handling as value object", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Spryker Systems GmbH", 9 | "homepage": "https://spryker.com" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=8.0", 14 | "ext-bcmath": "*", 15 | "ext-json": "*" 16 | }, 17 | "require-dev": { 18 | "phpunit/phpunit": "^9.5.0", 19 | "spryker/code-sniffer": "@stable", 20 | "phpstan/phpstan": "^1.0.0" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "Spryker\\DecimalObject\\": "src/" 25 | } 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "Spryker\\DecimalObject\\Test\\": "tests/" 30 | } 31 | }, 32 | "minimum-stability": "dev", 33 | "prefer-stable": true, 34 | "extra": { 35 | "branch-alias": { 36 | "dev-master": "1.x-dev" 37 | } 38 | }, 39 | "scripts": { 40 | "test": "phpunit", 41 | "test-coverage": "phpunit --log-junit tmp/coverage/unitreport.xml --coverage-html tmp/coverage --coverage-clover tmp/coverage/coverage.xml", 42 | "stan": "vendor/bin/phpstan.phar analyse -c tests/phpstan.neon -l 8 src/", 43 | "cs-check": "vendor/bin/phpcs --colors --standard=vendor/spryker/code-sniffer/Spryker/ruleset.xml -s -p src/ tests/", 44 | "cs-fix": "vendor/bin/phpcbf --colors --standard=vendor/spryker/code-sniffer/Spryker/ruleset.xml -p src/ tests/" 45 | }, 46 | "config": { 47 | "allow-plugins": { 48 | "dealerdirect/phpcodesniffer-composer-installer": true 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Decimal VO documentation 2 | 3 | Decimal value object for PHP. 4 | 5 | ## Basic information 6 | 7 | The value objects are immutable, always assign them when running modifications on them. 8 | 9 | ## Usage 10 | 11 | You can create the value object from 12 | - string 13 | - integer 14 | - float (careful) 15 | 16 | ```php 17 | use Spryker\DecimalObject\Decimal; 18 | 19 | $value = '1.234'; 20 | $decimalObject = Decimal::create($value); 21 | 22 | echo 'Value: ' . $decimalObject; 23 | ``` 24 | 25 | It will auto-cast to string where necessary, you can force it using `(string)$decimalObject`. 26 | 27 | ### Checks 28 | 29 | Boolean checks: 30 | - `isZero()`: If exactly `0`. 31 | - `isNegative()`: If < `0`. 32 | - `isPositive()`: If > `0` (zero itself is not included). 33 | 34 | ### Operations 35 | 36 | These return a new object: 37 | - `sign()`: Returns int `0` if zero, `-1` if negative, or `1` if positive. 38 | - `absolute()`: Returns the absolute (positive) value of this decimal. 39 | - `negation()`: Returns the negation (positive if negative and vice versa). 40 | - `trim()`: Remove trailing zeros after the comma (same value, but different semantic meaning in term of precision/scale). 41 | 42 | Also: 43 | - `toString()`: Default casting mechanism (this method is equivalent to a cast to string). 44 | - `toFloat()`: Returns some approximation of this Decimal as a PHP native float. 45 | - `toInt()`: Returns integer value (this method is equivalent to a cast to integer). 46 | 47 | There is only one static method and acts as a convenience wrapper to create an object: 48 | - `create()`: Internally does `new Decimal($value)`, allows for easier chaining without need of `()` wrapping. 49 | Use this if your input can already be the `Decimal` object as it will then just be re-used. Constructor creation 50 | builds a new object. 51 | 52 | ### Comparison 53 | 54 | - `compareTo()`: Compare this Decimal to another value. 55 | - `equals()`: This method is equivalent to the `==` operator. 56 | - `greaterThan()`: Returns true if the Decimal is greater than given value. 57 | - `lessThan()`: Returns true if the Decimal is smaller than given value. 58 | - `greaterThanOrEquals()`: Returns true if the Decimal is greater than or equal to the given value. 59 | - `lessThanOrEquals()`: Returns true if the Decimal is greater than or equal to the given value. 60 | 61 | ### Math and Calculation 62 | You can use 63 | - `add()` 64 | - `subtract()` 65 | - `multiply()` 66 | - `divide()` 67 | - `round()`: Round using different modes. 68 | - `truncate()`: Truncate after places of decimal. 69 | - `mod()` 70 | - `pow()` 71 | - `sqrt()` 72 | 73 | ```php 74 | $decimalOne = Decimal::create('1.1'); 75 | $decimalTwo = Decimal::create('2.2'); 76 | 77 | $decimalAdded = $decimalOne->add($decimalTwo); // Now '3.3' 78 | ``` 79 | 80 | Note that due to immutability `$decimalOne` is not modified here. The re-assignment is necessary for the operation to persist. 81 | 82 | 83 | ## Contributing 84 | 85 | You can contribute as pull request directly: 86 | - Target `master` branch for: bugfixes, improvements that are BC 87 | - Target `next` branch (next major version) for: Any BC breaking behavior, cleanup like removing deprecations or alike 88 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Spryker Coding Standard. 5 | 6 | Extends main Spryker Coding Standard. 7 | All sniffs in ./Sniffs will be auto loaded 8 | 9 | 10 | 11 | 12 | 13 | src/ 14 | tests/ 15 | 16 | 17 | 18 | 19 | 0 20 | 21 | 22 | 0 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | paths: 4 | - src/ 5 | checkMissingIterableValueType: false 6 | ignoreErrors: 7 | - '#Unsafe usage of new static\(\).#' 8 | -------------------------------------------------------------------------------- /src/Decimal.php: -------------------------------------------------------------------------------- 1 | 6 64 | * 65 | * @var int 66 | */ 67 | protected $scale; 68 | 69 | /** 70 | * @param static|string|float|int $value 71 | * @param int|null $scale 72 | */ 73 | public function __construct($value, ?int $scale = null) 74 | { 75 | $value = $this->parseValue($value); 76 | $value = $this->normalizeValue($value); 77 | 78 | $this->setValue($value, $scale); 79 | $this->setScale($scale); 80 | } 81 | 82 | /** 83 | * @return int 84 | */ 85 | public function scale(): int 86 | { 87 | return $this->scale; 88 | } 89 | 90 | /** 91 | * @param mixed $value 92 | * 93 | * @throws \InvalidArgumentException 94 | * 95 | * @return string 96 | */ 97 | protected function parseValue($value): string 98 | { 99 | if ($value !== null && !(is_scalar($value) || method_exists($value, '__toString'))) { 100 | throw new InvalidArgumentException('Invalid value'); 101 | } 102 | 103 | if (is_string($value) && !is_numeric(trim($value))) { 104 | throw new InvalidArgumentException('Invalid non numeric value'); 105 | } 106 | 107 | if (!is_string($value)) { 108 | $value = (string)$value; 109 | } 110 | 111 | return $value; 112 | } 113 | 114 | /** 115 | * @param string $value 116 | * 117 | * @return string 118 | */ 119 | protected function normalizeValue(string $value): string 120 | { 121 | $value = trim($value); 122 | /** @var string $value */ 123 | $value = preg_replace( 124 | [ 125 | '/^^([\-]?)(\.)(.*)$/', // omitted leading zero 126 | '/^0+(.)(\..*)?$/', // multiple leading zeros 127 | '/^(\+(.*)|(-)(0))$/', // leading positive sign, tolerate minus zero too 128 | ], 129 | [ 130 | '${1}0.${3}', 131 | '${1}${2}', 132 | '${4}${2}', 133 | ], 134 | $value, 135 | ); 136 | 137 | return $value; 138 | } 139 | 140 | /** 141 | * Returns a Decimal instance from the given value. 142 | * 143 | * If the value is already a Decimal instance, then (since immutable) return it unmodified. 144 | * Otherwise, create a new Decimal instance from the given value and return 145 | * it. 146 | * 147 | * @param static|string|float|int $value 148 | * @param int|null $scale 149 | * 150 | * @return static 151 | */ 152 | public static function create($value, ?int $scale = null) 153 | { 154 | if ($scale === null && $value instanceof static) { 155 | return clone $value; 156 | } 157 | 158 | return new static($value, $scale); 159 | } 160 | 161 | /** 162 | * Equality 163 | * 164 | * This method is equivalent to the `==` operator. 165 | * 166 | * @param static|string|float|int $value 167 | * 168 | * @return bool TRUE if this decimal is considered equal to the given value. 169 | * Equal decimal values tie-break on precision. 170 | */ 171 | public function equals($value): bool 172 | { 173 | return $this->compareTo($value) === 0; 174 | } 175 | 176 | /** 177 | * @param static|string|float|int $value 178 | * 179 | * @return bool 180 | */ 181 | public function greaterThan($value): bool 182 | { 183 | return $this->compareTo($value) > 0; 184 | } 185 | 186 | /** 187 | * @param static|string|float|int $value 188 | * 189 | * @return bool 190 | */ 191 | public function lessThan($value): bool 192 | { 193 | return $this->compareTo($value) < 0; 194 | } 195 | 196 | /** 197 | * @param static|string|float|int $value 198 | * 199 | * @return bool 200 | */ 201 | public function greaterThanOrEquals($value): bool 202 | { 203 | return ($this->compareTo($value) >= 0); 204 | } 205 | 206 | /** 207 | * @deprecated Use {@link greaterThanOrEquals()} instead. 208 | * 209 | * @param static|string|float|int $value 210 | * 211 | * @return bool 212 | */ 213 | public function greatherThanOrEquals($value): bool 214 | { 215 | return $this->greaterThanOrEquals($value); 216 | } 217 | 218 | /** 219 | * @param static|string|float|int $value 220 | * 221 | * @return bool 222 | */ 223 | public function lessThanOrEquals($value): bool 224 | { 225 | return ($this->compareTo($value) <= 0); 226 | } 227 | 228 | /** 229 | * Compare this Decimal with a value. 230 | * 231 | * Returns 232 | * - `-1` if the instance is less than the $value, 233 | * - `0` if the instance is equal to $value, or 234 | * - `1` if the instance is greater than $value. 235 | * 236 | * @param static|string|float|int $value 237 | * 238 | * @return int 239 | */ 240 | public function compareTo($value): int 241 | { 242 | $decimal = static::create($value); 243 | $scale = max($this->scale(), $decimal->scale()); 244 | 245 | return bccomp($this, $decimal, $scale); 246 | } 247 | 248 | /** 249 | * Add $value to this Decimal and return the sum as a new Decimal. 250 | * 251 | * @param static|string|float|int $value 252 | * @param int|null $scale 253 | * 254 | * @return static 255 | */ 256 | public function add($value, ?int $scale = null) 257 | { 258 | $decimal = static::create($value); 259 | $scale = $this->resultScale($this, $decimal, $scale); 260 | 261 | return new static(bcadd($this, $decimal, $scale)); 262 | } 263 | 264 | /** 265 | * Return an appropriate scale for an arithmetic operation on two Decimals. 266 | * 267 | * If $scale is specified and is a valid positive integer, return it. 268 | * Otherwise, return the higher of the scales of the operands. 269 | * 270 | * @param static $a 271 | * @param static $b 272 | * @param int|null $scale 273 | * 274 | * @return int 275 | */ 276 | protected function resultScale($a, $b, ?int $scale = null): int 277 | { 278 | if ($scale === null) { 279 | $scale = max($a->scale(), $b->scale()); 280 | } 281 | 282 | return $scale; 283 | } 284 | 285 | /** 286 | * Subtract $value from this Decimal and return the difference as a new 287 | * Decimal. 288 | * 289 | * @param static|string|float|int $value 290 | * @param int|null $scale 291 | * 292 | * @return static 293 | */ 294 | public function subtract($value, ?int $scale = null) 295 | { 296 | $decimal = static::create($value); 297 | $scale = $this->resultScale($this, $decimal, $scale); 298 | 299 | return new static(bcsub($this, $decimal, $scale)); 300 | } 301 | 302 | /** 303 | * Trims trailing zeroes. 304 | * 305 | * @return static 306 | */ 307 | public function trim() 308 | { 309 | return $this->copy($this->integralPart, $this->trimDecimals($this->fractionalPart)); 310 | } 311 | 312 | /** 313 | * @param string $value 314 | * 315 | * @return string 316 | */ 317 | protected function trimDecimals(string $value): string 318 | { 319 | return rtrim($value, '0') ?: ''; 320 | } 321 | 322 | /** 323 | * Signum 324 | * 325 | * @return int 0 if zero, -1 if negative, or 1 if positive. 326 | */ 327 | public function sign(): int 328 | { 329 | if ($this->isZero()) { 330 | return 0; 331 | } 332 | 333 | return $this->negative ? -1 : 1; 334 | } 335 | 336 | /** 337 | * Returns the absolute (positive) value of this decimal. 338 | * 339 | * @return static 340 | */ 341 | public function absolute() 342 | { 343 | return $this->copy($this->integralPart, $this->fractionalPart, false); 344 | } 345 | 346 | /** 347 | * Returns the negation (negative to positive and vice versa). 348 | * 349 | * @return static 350 | */ 351 | public function negate() 352 | { 353 | return $this->copy(null, null, !$this->isNegative()); 354 | } 355 | 356 | /** 357 | * @return bool 358 | */ 359 | public function isInteger(): bool 360 | { 361 | return trim($this->fractionalPart, '0') === ''; 362 | } 363 | 364 | /** 365 | * Returns if truly zero. 366 | * 367 | * @return bool 368 | */ 369 | public function isZero(): bool 370 | { 371 | return $this->integralPart === '0' && $this->isInteger(); 372 | } 373 | 374 | /** 375 | * Returns if truly negative (not zero). 376 | * 377 | * @return bool 378 | */ 379 | public function isNegative(): bool 380 | { 381 | return $this->negative; 382 | } 383 | 384 | /** 385 | * Returns if truly positive (not zero). 386 | * 387 | * @return bool 388 | */ 389 | public function isPositive(): bool 390 | { 391 | return !$this->negative && !$this->isZero(); 392 | } 393 | 394 | /** 395 | * Multiply this Decimal by $value and return the product as a new Decimal. 396 | * 397 | * @param static|string|float|int $value 398 | * @param int|null $scale 399 | * 400 | * @return static 401 | */ 402 | public function multiply($value, ?int $scale = null) 403 | { 404 | $decimal = static::create($value); 405 | if ($scale === null) { 406 | $scale = $this->scale() + $decimal->scale(); 407 | } 408 | 409 | return new static(bcmul($this, $decimal, $scale)); 410 | } 411 | 412 | /** 413 | * Divide this Decimal by $value and return the quotient as a new Decimal. 414 | * 415 | * @param static|string|float|int $value 416 | * @param int $scale 417 | * 418 | * @throws \DivisionByZeroError if $value is zero. 419 | * 420 | * @return static 421 | */ 422 | public function divide($value, int $scale) 423 | { 424 | $decimal = static::create($value); 425 | if ($decimal->isZero()) { 426 | throw new DivisionByZeroError('Cannot divide by zero. Only Chuck Norris can!'); 427 | } 428 | 429 | return new static(bcdiv($this, $decimal, $scale)); 430 | } 431 | 432 | /** 433 | * This method is equivalent to the ** operator. 434 | * 435 | * @param static|string|int $exponent 436 | * @param int|null $scale 437 | * 438 | * @return static 439 | */ 440 | public function pow($exponent, ?int $scale = null) 441 | { 442 | if ($scale === null) { 443 | $scale = $this->scale(); 444 | } 445 | 446 | return new static(bcpow($this, (string)$exponent, $scale)); 447 | } 448 | 449 | /** 450 | * Returns the square root of this decimal, with the same scale as this decimal. 451 | * 452 | * @param int|null $scale 453 | * 454 | * @return static 455 | */ 456 | public function sqrt(?int $scale = null) 457 | { 458 | if ($scale === null) { 459 | $scale = $this->scale(); 460 | } 461 | 462 | return new static(bcsqrt($this, $scale)); 463 | } 464 | 465 | /** 466 | * This method is equivalent to the % operator. 467 | * 468 | * @param static|string|int $value 469 | * @param int|null $scale 470 | * 471 | * @return static 472 | */ 473 | public function mod($value, ?int $scale = null) 474 | { 475 | if ($scale === null) { 476 | $scale = $this->scale(); 477 | } 478 | if (version_compare(PHP_VERSION, '7.2') < 0) { 479 | return new static(bcmod($this, (string)$value)); 480 | } 481 | 482 | return new static(bcmod($this, (string)$value, $scale)); 483 | } 484 | 485 | /** 486 | * @param int $scale 487 | * @param int $roundMode 488 | * 489 | * @return static 490 | */ 491 | public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP) 492 | { 493 | $exponent = $scale + 1; 494 | 495 | $e = bcpow('10', (string)$exponent); 496 | switch ($roundMode) { 497 | case static::ROUND_FLOOR: 498 | $v = bcdiv(bcadd(bcmul($this, $e, 0), $this->isNegative() ? '-9' : '0'), $e, 0); 499 | 500 | break; 501 | case static::ROUND_CEIL: 502 | $v = bcdiv(bcadd(bcmul($this, $e, 0), $this->isNegative() ? '0' : '9'), $e, 0); 503 | 504 | break; 505 | case static::ROUND_HALF_UP: 506 | default: 507 | $v = bcdiv(bcadd(bcmul($this, $e, 0), $this->isNegative() ? '-5' : '5'), $e, $scale); 508 | } 509 | 510 | return new static($v); 511 | } 512 | 513 | /** 514 | * The closest integer towards negative infinity. 515 | * 516 | * @return static 517 | */ 518 | public function floor() 519 | { 520 | return $this->round(0, static::ROUND_FLOOR); 521 | } 522 | 523 | /** 524 | * The closest integer towards positive infinity. 525 | * 526 | * @return static 527 | */ 528 | public function ceil() 529 | { 530 | return $this->round(0, static::ROUND_CEIL); 531 | } 532 | 533 | /** 534 | * The result of discarding all digits behind the defined scale. 535 | * 536 | * @param int $scale 537 | * 538 | * @throws \InvalidArgumentException 539 | * 540 | * @return static 541 | */ 542 | public function truncate(int $scale = 0) 543 | { 544 | if ($scale < 0) { 545 | throw new InvalidArgumentException('Scale must be >= 0.'); 546 | } 547 | 548 | $decimalPart = substr($this->fractionalPart, 0, $scale); 549 | 550 | return $this->copy($this->integralPart, $decimalPart); 551 | } 552 | 553 | /** 554 | * Return some approximation of this Decimal as a PHP native float. 555 | * 556 | * Due to the nature of binary floating-point, some valid values of Decimal 557 | * will not have any finite representation as a float, and some valid 558 | * values of Decimal will be out of the range handled by floats. 559 | * 560 | * @throws \TypeError 561 | * 562 | * @return float 563 | */ 564 | public function toFloat(): float 565 | { 566 | if ($this->isBigDecimal()) { 567 | throw new TypeError('Cannot cast Big Decimal to Float'); 568 | } 569 | 570 | return (float)$this->toString(); 571 | } 572 | 573 | /** 574 | * Returns the decimal as int. Does not round. 575 | * 576 | * This method is equivalent to a cast to int. 577 | * 578 | * @throws \TypeError 579 | * 580 | * @return int 581 | */ 582 | public function toInt(): int 583 | { 584 | if ($this->isBigInteger()) { 585 | throw new TypeError('Cannot cast Big Integer to Integer'); 586 | } 587 | 588 | return (int)$this->toString(); 589 | } 590 | 591 | /** 592 | * @return bool 593 | */ 594 | public function isBigInteger(): bool 595 | { 596 | return bccomp($this->integralPart, (string)PHP_INT_MAX) === 1 || bccomp($this->integralPart, (string)PHP_INT_MIN) === -1; 597 | } 598 | 599 | /** 600 | * @return bool 601 | */ 602 | public function isBigDecimal(): bool 603 | { 604 | return $this->isBigInteger() || 605 | bccomp($this->fractionalPart, (string)PHP_INT_MAX) === 1 || bccomp($this->fractionalPart, (string)PHP_INT_MIN) === -1; 606 | } 607 | 608 | /** 609 | * Returns scientific notation. 610 | * 611 | * {x.y}e{z} with 0 < x < 10 612 | * 613 | * This does not lose precision/scale info. 614 | * If you want the output without the significant digits added, 615 | * use trim() beforehand. 616 | * 617 | * @return string 618 | */ 619 | public function toScientific(): string 620 | { 621 | if ($this->integralPart) { 622 | $exponent = 0; 623 | $integralPart = $this->integralPart; 624 | while ($integralPart >= 10) { 625 | $integralPart /= 10; 626 | $exponent++; 627 | } 628 | 629 | $value = (string)$integralPart; 630 | if (strpos($value, '.') === false) { 631 | $value .= '.'; 632 | } 633 | $value .= $this->fractionalPart; 634 | } else { 635 | $exponent = -1; 636 | // 00002 637 | // 20000 638 | $fractionalPart = $this->fractionalPart; 639 | while (substr($fractionalPart, 0, 1) === '0') { 640 | $fractionalPart = substr($fractionalPart, 1); 641 | $exponent--; 642 | } 643 | 644 | $pos = abs($exponent) - 1; 645 | $value = substr($this->fractionalPart, $pos, 1) . '.' . substr($this->fractionalPart, $pos + 1); 646 | } 647 | 648 | if ($this->negative) { 649 | $value = '-' . $value; 650 | } 651 | 652 | return $value . 'e' . $exponent; 653 | } 654 | 655 | /** 656 | * String representation. 657 | * 658 | * This method is equivalent to a cast to string. 659 | * 660 | * This method should not be used as a canonical representation of this 661 | * decimal, because values can be represented in more than one way. However, 662 | * this method does guarantee that a decimal instantiated by its output with 663 | * the same scale will be exactly equal to this decimal. 664 | * 665 | * @return string the value of this decimal represented exactly. 666 | */ 667 | public function toString(): string 668 | { 669 | if ($this->fractionalPart !== '') { 670 | return ($this->negative ? '-' : '') . $this->integralPart . '.' . $this->fractionalPart; 671 | } 672 | 673 | return ($this->negative ? '-' : '') . $this->integralPart; 674 | } 675 | 676 | /** 677 | * Return a basic string representation of this Decimal. 678 | * 679 | * The output of this method is guaranteed to yield exactly the same value 680 | * if fed back into the Decimal constructor. 681 | * 682 | * @return string 683 | */ 684 | public function __toString(): string 685 | { 686 | return $this->toString(); 687 | } 688 | 689 | /** 690 | * Get the printable version of this object 691 | * 692 | * @return array 693 | */ 694 | public function __debugInfo(): array 695 | { 696 | return [ 697 | 'value' => $this->toString(), 698 | 'scale' => $this->scale, 699 | ]; 700 | } 701 | 702 | /** 703 | * @return string 704 | */ 705 | public function jsonSerialize(): string 706 | { 707 | return $this->toString(); 708 | } 709 | 710 | /** 711 | * @param string|null $integerPart 712 | * @param string|null $decimalPart 713 | * @param bool|null $negative 714 | * 715 | * @return static 716 | */ 717 | protected function copy(?string $integerPart = null, ?string $decimalPart = null, ?bool $negative = null) 718 | { 719 | $clone = clone $this; 720 | if ($integerPart !== null) { 721 | $clone->integralPart = $integerPart; 722 | } 723 | if ($decimalPart !== null) { 724 | $clone->fractionalPart = $decimalPart; 725 | $clone->setScale(null); 726 | } 727 | if ($negative !== null) { 728 | $clone->negative = $negative; 729 | } 730 | 731 | return $clone; 732 | } 733 | 734 | /** 735 | * Separates int and decimal parts and adds them to the state. 736 | * 737 | * - Removes leading 0 on int part 738 | * - '0.00001' can also come in as '1.0E-5' 739 | * 740 | * @param string $value 741 | * @param int|null $scale 742 | * 743 | * @return void 744 | */ 745 | protected function setValue(string $value, ?int $scale): void 746 | { 747 | if (preg_match('#(.+)e(.+)#i', $value) === 1) { 748 | $this->fromScientific($value, $scale); 749 | 750 | return; 751 | } 752 | 753 | if (strpos($value, '.') !== false) { 754 | $this->fromFloat($value); 755 | 756 | return; 757 | } 758 | 759 | $this->fromInt($value); 760 | } 761 | 762 | /** 763 | * @param string $value 764 | * 765 | * @throws \InvalidArgumentException 766 | * 767 | * @return void 768 | */ 769 | protected function fromInt(string $value): void 770 | { 771 | preg_match('/^(-)?([^.]+)$/', $value, $matches); 772 | if ($matches === []) { 773 | throw new InvalidArgumentException('Invalid integer number'); 774 | } 775 | 776 | $this->negative = $matches[1] === '-'; 777 | $this->integralPart = $matches[2]; 778 | $this->fractionalPart = ''; 779 | } 780 | 781 | /** 782 | * @param string $value 783 | * 784 | * @throws \InvalidArgumentException 785 | * 786 | * @return void 787 | */ 788 | protected function fromFloat(string $value): void 789 | { 790 | preg_match('/^(-)?(.*)\.(.*)$/', $value, $matches); 791 | if ($matches === []) { 792 | throw new InvalidArgumentException('Invalid float number'); 793 | } 794 | 795 | $this->negative = $matches[1] === '-'; 796 | $this->integralPart = $matches[2]; 797 | $this->fractionalPart = $matches[3]; 798 | } 799 | 800 | /** 801 | * @param string $value 802 | * @param int|null $scale 803 | * 804 | * @throws \InvalidArgumentException 805 | * 806 | * @return void 807 | */ 808 | protected function fromScientific(string $value, ?int $scale): void 809 | { 810 | $pattern = '/^(-?)(\d+(?:' . static::RADIX_MARK . '\d*)?|' . 811 | '[' . static::RADIX_MARK . ']' . '\d+)' . static::EXP_MARK . '(-?\d*)?$/i'; 812 | preg_match($pattern, $value, $matches); 813 | if (!$matches) { 814 | throw new InvalidArgumentException('Invalid scientific value/notation: ' . $value); 815 | } 816 | 817 | $this->negative = $matches[1] === '-'; 818 | /** @var string $value */ 819 | $value = preg_replace('/\b\.0$/', '', $matches[2]); 820 | $exp = (int)$matches[3]; 821 | 822 | if ($exp < 0) { 823 | $this->integralPart = '0'; 824 | /** @var string $value */ 825 | $value = preg_replace('/^(\d+)(\.)?(\d+)$/', '${1}${3}', $value, 1); 826 | $this->fractionalPart = str_repeat('0', -$exp - 1) . $value; 827 | 828 | if ($scale !== null) { 829 | $this->fractionalPart = str_pad($this->fractionalPart, $scale, '0'); 830 | } 831 | } else { 832 | $this->integralPart = bcmul($matches[2], bcpow('10', (string)$exp)); 833 | 834 | $pos = strlen((string)$this->integralPart); 835 | if (strpos($value, '.') !== false) { 836 | $pos++; 837 | } 838 | $this->fractionalPart = rtrim(substr($value, $pos), '.'); 839 | 840 | if ($scale !== null) { 841 | $this->fractionalPart = str_pad($this->fractionalPart, $scale - strlen((string)$this->integralPart), '0'); 842 | } 843 | } 844 | } 845 | 846 | /** 847 | * @param int|null $scale 848 | * 849 | * @throws \InvalidArgumentException 850 | * 851 | * @return void 852 | */ 853 | protected function setScale(?int $scale): void 854 | { 855 | $calculatedScale = strlen($this->fractionalPart); 856 | if ($scale && $calculatedScale > $scale) { 857 | throw new InvalidArgumentException('Loss of precision detected. Detected scale `' . $calculatedScale . '` > `' . $scale . '` as defined.'); 858 | } 859 | 860 | $this->scale = $scale ?? $calculatedScale; 861 | } 862 | } 863 | -------------------------------------------------------------------------------- /tests/DecimalTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expected, (string)$decimal); 31 | } 32 | 33 | /** 34 | * @return void 35 | */ 36 | public function testNewObjectScientific(): void 37 | { 38 | $value = '2.2e-6'; 39 | $decimal = new Decimal($value); 40 | $result = $decimal->toString(); 41 | $this->assertSame('0.0000022', $result); 42 | 43 | $this->assertSame(7, $decimal->scale()); 44 | } 45 | 46 | /** 47 | * @dataProvider baseProvider 48 | * 49 | * @param mixed $value 50 | * @param string $expected 51 | * 52 | * @return void 53 | */ 54 | public function testCreate($value, string $expected): void 55 | { 56 | $decimal = Decimal::create($value); 57 | $this->assertSame($expected, (string)$decimal); 58 | } 59 | 60 | /** 61 | * @return array 62 | */ 63 | public function baseProvider(): array 64 | { 65 | $objectWithToStringMethod = new class 66 | { 67 | /** 68 | * @return string 69 | */ 70 | public function __toString(): string 71 | { 72 | return '12.12'; 73 | } 74 | }; 75 | 76 | return [ 77 | [1.1, '1.1'], 78 | [-23, '-23'], 79 | [50, '50'], 80 | [-25000, '-25000'], 81 | [0.00001, '0.00001'], 82 | [-0.000003, '-0.000003'], 83 | ['.0189', '0.0189'], 84 | ['-.3', '-0.3'], 85 | ['-5.000067', '-5.000067'], 86 | ['+5.000067', '5.000067'], 87 | ['0000005', '5'], 88 | ['000000.5', '0.5'], 89 | [' 0.0 ', '0.0'], 90 | ['6.22e8', '622000000'], 91 | ['6.22e18', '6220000000000000000'], 92 | [PHP_INT_MAX, (string)PHP_INT_MAX], 93 | [PHP_INT_MAX . '.' . PHP_INT_MAX, PHP_INT_MAX . '.' . PHP_INT_MAX], 94 | [-PHP_INT_MAX, '-' . PHP_INT_MAX], 95 | [Decimal::create('-12.375'), '-12.375'], 96 | ['0000', '0'], 97 | ['-0', '0'], 98 | ['+0', '0'], 99 | ['311000000000000000000000', '311000000000000000000000'], 100 | ['3.11e23', '311000000000000000000000'], 101 | ['622000000000000000000000', '622000000000000000000000'], 102 | ['3.11e2', '311'], 103 | [$objectWithToStringMethod, '12.12'], 104 | ]; 105 | } 106 | 107 | /** 108 | * @dataProvider invalidValuesProvider 109 | * 110 | * @param mixed $value 111 | * 112 | * @return void 113 | */ 114 | public function testNewObjectWithInvalidValueThrowsException($value): void 115 | { 116 | $this->expectException(InvalidArgumentException::class); 117 | 118 | Decimal::create($value); 119 | } 120 | 121 | /** 122 | * @return array 123 | */ 124 | public function invalidValuesProvider(): array 125 | { 126 | return [ 127 | 'invalid string' => ['xyz'], 128 | 'object' => [new stdClass()], 129 | 'non-english/localized case1' => ['1018,9'], 130 | 'non-english/localized case2' => ['1.018,9'], 131 | 'null' => [null], 132 | ]; 133 | } 134 | 135 | /** 136 | * @dataProvider truncateProvider 137 | * 138 | * @param mixed $input 139 | * @param int $scale 140 | * @param string $expected 141 | * 142 | * @return void 143 | */ 144 | public function testTruncate($input, int $scale, string $expected): void 145 | { 146 | $decimal = Decimal::create($input); 147 | $this->assertSame($expected, (string)$decimal->truncate($scale)); 148 | } 149 | 150 | /** 151 | * @return array 152 | */ 153 | public function truncateProvider(): array 154 | { 155 | return [ 156 | [0, 0, '0'], 157 | [1, 0, '1'], 158 | [-1, 0, '-1'], 159 | ['12.375', 2, '12.37'], 160 | ['12.374', 0, '12'], 161 | ['-12.376', 1, '-12.3'], 162 | ]; 163 | } 164 | 165 | /** 166 | * @dataProvider integerProvider 167 | * 168 | * @param mixed $value 169 | * @param bool $expected 170 | * 171 | * @return void 172 | */ 173 | public function testIsInteger($value, bool $expected): void 174 | { 175 | $decimal = Decimal::create($value); 176 | $this->assertSame($expected, $decimal->isInteger()); 177 | } 178 | 179 | /** 180 | * @return array 181 | */ 182 | public function integerProvider(): array 183 | { 184 | return [ 185 | [5, true], 186 | [0.00001, false], 187 | [-0.000003, false], 188 | [Decimal::create('0'), true], 189 | [0, true], 190 | [0.0, true], 191 | ['0000', true], 192 | ['-0', true], 193 | ['+0', true], 194 | [-121211, true], 195 | ]; 196 | } 197 | 198 | /** 199 | * @dataProvider zeroProvider 200 | * 201 | * @param mixed $value 202 | * @param bool $expected 203 | * 204 | * @return void 205 | */ 206 | public function testIsZero($value, bool $expected): void 207 | { 208 | $decimal = Decimal::create($value); 209 | $this->assertSame($expected, $decimal->isZero()); 210 | } 211 | 212 | /** 213 | * @return array 214 | */ 215 | public function zeroProvider(): array 216 | { 217 | return [ 218 | [5, false], 219 | [0.00001, false], 220 | [-0.000003, false], 221 | [Decimal::create('0'), true], 222 | [0, true], 223 | [0.0, true], 224 | ['0000', true], 225 | ['-0', true], 226 | ['+0', true], 227 | ]; 228 | } 229 | 230 | /** 231 | * @dataProvider compareZeroProvider 232 | * 233 | * @param mixed $input 234 | * @param int $expected 235 | * 236 | * @return void 237 | */ 238 | public function testIsPositive($input, int $expected): void 239 | { 240 | $decimal = Decimal::create($input); 241 | $this->assertSame($expected > 0, $decimal->isPositive()); 242 | } 243 | 244 | /** 245 | * @dataProvider compareZeroProvider 246 | * 247 | * @param mixed $input 248 | * @param int $expected 249 | * 250 | * @return void 251 | */ 252 | public function testIsNegative($input, int $expected): void 253 | { 254 | $decimal = Decimal::create($input); 255 | $this->assertSame($expected < 0, $decimal->isNegative()); 256 | } 257 | 258 | /** 259 | * @return array 260 | */ 261 | public function compareZeroProvider(): array 262 | { 263 | return [ 264 | [0, 0], 265 | [1, 1], 266 | [-1, -1], 267 | [0.0, 0], 268 | ['0', 0], 269 | ['1', 1], 270 | ['-1', -1], 271 | ['00000', 0], 272 | ['0.0', 0], 273 | ['0.00001', 1], 274 | ['1e-20', 1], 275 | ['-1e-20', -1], 276 | ]; 277 | } 278 | 279 | /** 280 | * @dataProvider scaleProvider 281 | * 282 | * @param mixed $input 283 | * @param int $expected 284 | * 285 | * @return void 286 | */ 287 | public function testScale($input, int $expected): void 288 | { 289 | $decimal = Decimal::create($input); 290 | $this->assertSame($expected, $decimal->scale()); 291 | } 292 | 293 | /** 294 | * @return array 295 | */ 296 | public function scaleProvider(): array 297 | { 298 | return [ 299 | [0, 0], 300 | [1, 0], 301 | [-1, 0], 302 | ['120', 0], 303 | ['12.375', 3], 304 | ['-0.7', 1], 305 | ['6.22e23', 0], 306 | ['1e-10', 10], 307 | ['-2.3e-10', 11], // 0.00000000023 308 | ]; 309 | } 310 | 311 | /** 312 | * @dataProvider scientificProvider 313 | * 314 | * @param mixed $value 315 | * @param string $expected 316 | * 317 | * @return void 318 | */ 319 | public function testToScientific($value, string $expected): void 320 | { 321 | $decimal = Decimal::create($value); 322 | $this->assertSame($expected, $decimal->toScientific()); 323 | $revertedDecimal = Decimal::create($decimal->toScientific()); 324 | $this->assertSame($value, (string)$revertedDecimal); 325 | } 326 | 327 | /** 328 | * @return array 329 | */ 330 | public function scientificProvider(): array 331 | { 332 | return [ 333 | ['-23', '-2.3e1'], 334 | ['1.000', '1.000e0'], 335 | ['-22.345', '-2.2345e1'], 336 | ['30022.0345', '3.00220345e4'], 337 | ['-0.00230', '-2.30e-3'], 338 | ]; 339 | } 340 | 341 | /** 342 | * @return void 343 | */ 344 | public function testToString(): void 345 | { 346 | $value = -23; 347 | $decimal = Decimal::create($value); 348 | 349 | $result = (string)$decimal; 350 | $this->assertSame('-23', $result); 351 | } 352 | 353 | /** 354 | * @return void 355 | */ 356 | public function testTrim(): void 357 | { 358 | $value = '-2.0300000000000000000000000000'; 359 | $decimal = Decimal::create($value); 360 | $this->assertSame(28, $decimal->scale()); 361 | 362 | $trimmed = $decimal->trim(); 363 | $this->assertSame('-2.03', (string)$trimmed); 364 | $this->assertSame(2, $trimmed->scale()); 365 | 366 | $value = '2000'; 367 | $decimal = Decimal::create($value); 368 | 369 | $trimmed = $decimal->trim(); 370 | $this->assertSame('2000', (string)$trimmed); 371 | $this->assertSame(0, $trimmed->scale()); 372 | } 373 | 374 | /** 375 | * @return void 376 | */ 377 | public function testToFloat(): void 378 | { 379 | $value = '-23.44'; 380 | $decimal = Decimal::create($value); 381 | 382 | $result = $decimal->toFloat(); 383 | $this->assertSame(-23.44, $result); 384 | } 385 | 386 | /** 387 | * @dataProvider bigFloatDataProvider 388 | * 389 | * @param string $value 390 | * 391 | * @return void 392 | */ 393 | public function testToFloatForBigDecimalThrowsAnException(string $value): void 394 | { 395 | $decimal = Decimal::create($value); 396 | 397 | $this->expectException(TypeError::class); 398 | $this->expectErrorMessage('Cannot cast Big Decimal to Float'); 399 | 400 | $result = $decimal->toFloat(); 401 | } 402 | 403 | /** 404 | * @return array 405 | */ 406 | public function bigFloatDataProvider(): array 407 | { 408 | return [ 409 | 'positive' => ['2.6' . PHP_INT_MAX], 410 | 'negative' => ['-2.6' . PHP_INT_MAX], 411 | ]; 412 | } 413 | 414 | /** 415 | * @return void 416 | */ 417 | public function testToInt(): void 418 | { 419 | $value = '-23.74'; 420 | $decimal = Decimal::create($value); 421 | 422 | $result = $decimal->toInt(); 423 | $this->assertSame(-23, $result); 424 | } 425 | 426 | /** 427 | * @dataProvider bigIntDataProvider 428 | * 429 | * @param string $value 430 | * 431 | * @return void 432 | */ 433 | public function testToIntForBigIntThrowsAnException(string $value): void 434 | { 435 | $decimal = Decimal::create($value); 436 | 437 | $this->expectException(TypeError::class); 438 | $this->expectErrorMessage('Cannot cast Big Integer to Integer'); 439 | 440 | $decimal->toInt(); 441 | } 442 | 443 | /** 444 | * @return array 445 | */ 446 | public function bigIntDataProvider(): array 447 | { 448 | return [ 449 | 'positive' => ['9' . PHP_INT_MAX], 450 | 'negative' => ['-9' . PHP_INT_MAX], 451 | ]; 452 | } 453 | 454 | /** 455 | * @return void 456 | */ 457 | public function testMod(): void 458 | { 459 | $value = '7'; 460 | $decimal = Decimal::create($value); 461 | 462 | $result = $decimal->mod(2); 463 | $this->assertSame('1', (string)$result); 464 | } 465 | 466 | /** 467 | * @return void 468 | */ 469 | public function testPow(): void 470 | { 471 | $value = '8'; 472 | $decimal = Decimal::create($value); 473 | 474 | $result = $decimal->pow(2); 475 | $this->assertSame('64', (string)$result); 476 | } 477 | 478 | /** 479 | * @return void 480 | */ 481 | public function testSqrt(): void 482 | { 483 | $value = '64'; 484 | $decimal = Decimal::create($value); 485 | 486 | $result = $decimal->sqrt(); 487 | $this->assertSame('8', (string)$result); 488 | 489 | $value = '18'; 490 | $decimal = Decimal::create($value); 491 | 492 | $result = $decimal->sqrt(5); 493 | $this->assertSame('4.24264', (string)$result); 494 | 495 | $value = '18.000000'; 496 | $decimal = Decimal::create($value); 497 | 498 | $result = $decimal->sqrt(); 499 | $this->assertSame('4.242640', (string)$result); 500 | } 501 | 502 | /** 503 | * @return void 504 | */ 505 | public function testAbsolute(): void 506 | { 507 | $value = '-23.44'; 508 | $decimal = Decimal::create($value); 509 | 510 | $result = $decimal->absolute(); 511 | $this->assertSame('23.44', (string)$result); 512 | } 513 | 514 | /** 515 | * @return void 516 | */ 517 | public function testNegate(): void 518 | { 519 | $value = '-23.44'; 520 | $decimal = Decimal::create($value); 521 | 522 | $result = $decimal->negate(); 523 | $this->assertSame('23.44', (string)$result); 524 | 525 | $again = $result->negate(); 526 | $this->assertSame($value, (string)$again); 527 | } 528 | 529 | /** 530 | * @return void 531 | */ 532 | public function testIsNegativeBasic(): void 533 | { 534 | $value = '-23.44'; 535 | $decimal = Decimal::create($value); 536 | $this->assertTrue($decimal->isNegative()); 537 | 538 | $value = '23.44'; 539 | $decimal = Decimal::create($value); 540 | $this->assertFalse($decimal->isNegative()); 541 | 542 | $value = '0'; 543 | $decimal = Decimal::create($value); 544 | $this->assertFalse($decimal->isNegative()); 545 | } 546 | 547 | /** 548 | * @return void 549 | */ 550 | public function testIsPositiveBasic(): void 551 | { 552 | $value = '-23.44'; 553 | $decimal = Decimal::create($value); 554 | $this->assertFalse($decimal->isPositive()); 555 | 556 | $value = '23.44'; 557 | $decimal = Decimal::create($value); 558 | $this->assertTrue($decimal->isPositive()); 559 | 560 | $value = '0'; 561 | $decimal = Decimal::create($value); 562 | $this->assertFalse($decimal->isPositive()); 563 | } 564 | 565 | /** 566 | * @return void 567 | */ 568 | public function testEquals(): void 569 | { 570 | $value = '1.1'; 571 | $decimalOne = Decimal::create($value); 572 | 573 | $value = '1.10'; 574 | $decimalTwo = Decimal::create($value); 575 | 576 | $result = $decimalOne->equals($decimalTwo); 577 | $this->assertTrue($result); 578 | } 579 | 580 | /** 581 | * @dataProvider roundProvider 582 | * 583 | * @param mixed $value 584 | * @param int $scale 585 | * @param string $expected 586 | * 587 | * @return void 588 | */ 589 | public function testRound($value, int $scale, string $expected): void 590 | { 591 | $decimal = Decimal::create($value); 592 | $this->assertSame($expected, (string)$decimal->round($scale)); 593 | $this->assertNativeRound($expected, $value, $scale, PHP_ROUND_HALF_UP); 594 | } 595 | 596 | /** 597 | * @param string $expected 598 | * @param mixed $value 599 | * @param int $scale 600 | * @param int $roundMode 601 | * 602 | * @return void 603 | */ 604 | protected function assertNativeRound(string $expected, $value, int $scale, int $roundMode): void 605 | { 606 | $this->assertSame((new Decimal($expected))->trim()->toString(), (string)round($value, $scale, $roundMode)); 607 | } 608 | 609 | /** 610 | * @return array 611 | */ 612 | public function roundProvider(): array 613 | { 614 | return [ 615 | [0, 0, '0'], 616 | [1, 0, '1'], 617 | [11, 2, '11.00'], 618 | [-1, 0, '-1'], 619 | [-5, 1, '-5.0'], 620 | ['12.375', 1, '12.4'], 621 | ['12.374', 2, '12.37'], 622 | ['12.375', 2, '12.38'], 623 | ['12.364', 2, '12.36'], 624 | ['12.365', 2, '12.37'], 625 | ['-13.574', 0, '-14'], 626 | [13.4999, 0, '13'], 627 | [13.4999, 10, '13.4999000000'], 628 | [13.4999, 2, '13.50'], 629 | ]; 630 | } 631 | 632 | /** 633 | * @dataProvider floorProvider 634 | * 635 | * @param mixed $value 636 | * @param string $expected 637 | * 638 | * @return void 639 | */ 640 | public function testFloor($value, string $expected): void 641 | { 642 | $decimal = Decimal::create($value); 643 | $this->assertSame($expected, (string)$decimal->floor()); 644 | $this->assertNativeFloor($expected, $value); 645 | } 646 | 647 | /** 648 | * @param string $expected 649 | * @param mixed $value 650 | * 651 | * @return void 652 | */ 653 | protected function assertNativeFloor(string $expected, $value): void 654 | { 655 | $this->assertSame($expected, (string)floor($value)); 656 | } 657 | 658 | /** 659 | * @return array 660 | */ 661 | public function floorProvider(): array 662 | { 663 | return [ 664 | [0, '0'], 665 | [1, '1'], 666 | [100, '100'], 667 | [-1, '-1'], 668 | [-7, '-7'], 669 | ['12.375', '12'], 670 | ['-13.574', '-14'], 671 | ['-13.8', '-14'], 672 | ['-13.1', '-14'], 673 | ['13.6999', '13'], 674 | ['13.1', '13'], 675 | ['13.9', '13'], 676 | ]; 677 | } 678 | 679 | /** 680 | * @dataProvider ceilProvider 681 | * 682 | * @param mixed $value 683 | * @param string $expected 684 | * 685 | * @return void 686 | */ 687 | public function testCeil($value, string $expected): void 688 | { 689 | $decimal = Decimal::create($value); 690 | $this->assertSame($expected, (string)$decimal->ceil()); 691 | $this->assertNativeCeil($expected, $value); 692 | } 693 | 694 | /** 695 | * @param string $expected 696 | * @param mixed $value 697 | * 698 | * @return void 699 | */ 700 | protected function assertNativeCeil(string $expected, $value): void 701 | { 702 | $this->assertSame($expected, (string)ceil($value)); 703 | } 704 | 705 | /** 706 | * @return array 707 | */ 708 | public function ceilProvider(): array 709 | { 710 | return [ 711 | [0, '0'], 712 | [1, '1'], 713 | [100, '100'], 714 | [-1, '-1'], 715 | [-2, '-2'], 716 | ['12.375', '13'], 717 | ['-13.574', '-13'], 718 | ['-13.8', '-13'], 719 | ['-13.1', '-13'], 720 | ['13.6999', '14'], 721 | ['13.1', '14'], 722 | ['13.9', '14'], 723 | ]; 724 | } 725 | 726 | /** 727 | * @dataProvider compareProvider 728 | * 729 | * @param mixed $a 730 | * @param mixed $b 731 | * @param int $expected 732 | * 733 | * @return void 734 | */ 735 | public function testGreaterThan($a, $b, int $expected): void 736 | { 737 | $decimal = Decimal::create($a); 738 | $this->assertSame($expected > 0, $decimal->greaterThan($b)); 739 | } 740 | 741 | /** 742 | * @dataProvider compareProvider 743 | * 744 | * @param mixed $a 745 | * @param mixed $b 746 | * @param int $expected 747 | * 748 | * @return void 749 | */ 750 | public function testLessThan($a, $b, int $expected): void 751 | { 752 | $decimal = Decimal::create($a); 753 | $this->assertSame($expected < 0, $decimal->lessThan($b)); 754 | } 755 | 756 | /** 757 | * @dataProvider compareProvider 758 | * 759 | * @param mixed $a 760 | * @param mixed $b 761 | * @param int $expected 762 | * 763 | * @return void 764 | */ 765 | public function testGreaterEquals($a, $b, int $expected): void 766 | { 767 | $decimal = Decimal::create($a); 768 | $this->assertSame($expected >= 0, $decimal->greaterThanOrEquals($b)); 769 | } 770 | 771 | /** 772 | * @dataProvider compareProvider 773 | * 774 | * @param mixed $a 775 | * @param mixed $b 776 | * @param int $expected 777 | * 778 | * @return void 779 | */ 780 | public function testLessEquals($a, $b, int $expected): void 781 | { 782 | $decimal = Decimal::create($a); 783 | $this->assertSame($expected <= 0, $decimal->lessThanOrEquals($b)); 784 | } 785 | 786 | /** 787 | * @return array 788 | */ 789 | public function compareProvider(): array 790 | { 791 | return [ 792 | [0, 0, 0], 793 | [1, 0, 1], 794 | [-1, 0, -1], 795 | ['12.375', '12.375', 0], 796 | ['12.374', '12.375', -1], 797 | ['12.376', '12.375', 1], 798 | ['6.22e23', '6.22e23', 0], 799 | ['1e-10', '1e-9', -1], 800 | ]; 801 | } 802 | 803 | /** 804 | * @return void 805 | */ 806 | public function testAdd(): void 807 | { 808 | $value = '1.1'; 809 | $decimalOne = Decimal::create($value); 810 | 811 | $value = '1.2'; 812 | $decimalTwo = Decimal::create($value); 813 | 814 | $result = $decimalOne->add($decimalTwo); 815 | 816 | $this->assertSame('2.3', (string)$result); 817 | $this->assertSame(1, $result->scale()); 818 | } 819 | 820 | /** 821 | * @return void 822 | */ 823 | public function testSubtract(): void 824 | { 825 | $value = '0.1'; 826 | $decimalOne = Decimal::create($value); 827 | 828 | $value = '0.01'; 829 | $decimalTwo = Decimal::create($value); 830 | 831 | $result = $decimalOne->subtract($decimalTwo); 832 | $this->assertSame('0.09', (string)$result); 833 | } 834 | 835 | /** 836 | * @dataProvider multiplicationProvider 837 | * 838 | * @param mixed $a 839 | * @param mixed $b 840 | * @param int|null $scale 841 | * @param string $expected 842 | * 843 | * @return void 844 | */ 845 | public function testMultiply($a, $b, ?int $scale, string $expected): void 846 | { 847 | $decimal = Decimal::create($a); 848 | $this->assertSame($expected, (string)$decimal->multiply($b, $scale)); 849 | } 850 | 851 | /** 852 | * @return array 853 | */ 854 | public function multiplicationProvider(): array 855 | { 856 | return [ 857 | ['0', '0', null, '0'], 858 | ['1', '10', null, '10'], 859 | ['1000', '10', null, '10000'], 860 | ['-10', '10', null, '-100'], 861 | ['10', '-10', null, '-100'], 862 | ['10', '10', null, '100'], 863 | ['0.1', '1', null, '0.1'], 864 | ['0.1', '0.01', null, '0.001'], 865 | ['-0.001', '0.01', null, '-0.00001'], 866 | ['9', '0.001', 3, '0.009'], 867 | ['9', '0.001', 0, '0'], 868 | ['1e-10', '28', null, '0.0000000028'], 869 | ['1e-10', '-1e-10', null, '-0.00000000000000000001'], 870 | ['1e-10', '-1e-10', 20, '-0.00000000000000000001'], 871 | ['1e-10', '-1e-10', 19, '0.0000000000000000000'], 872 | ]; 873 | } 874 | 875 | /** 876 | * @dataProvider multiplicationLegacyProvider 877 | * 878 | * @param mixed $a 879 | * @param mixed $b 880 | * @param int|null $scale 881 | * @param string $expected 882 | * 883 | * @return void 884 | */ 885 | public function testMultiplyLegacy($a, $b, ?int $scale, string $expected): void 886 | { 887 | $decimal = Decimal::create($a); 888 | $this->assertSame($expected, (string)$decimal->multiply($b, $scale)); 889 | } 890 | 891 | /** 892 | * @return array 893 | */ 894 | public function multiplicationLegacyProvider(): array 895 | { 896 | return [ 897 | ['0', '0', 3, version_compare(PHP_VERSION, '7.3') < 0 ? '0' : '0.000'], 898 | ]; 899 | } 900 | 901 | /** 902 | * @dataProvider divisionProvider 903 | * 904 | * @param mixed $a 905 | * @param mixed $b 906 | * @param int $scale 907 | * @param string $expected 908 | * 909 | * @return void 910 | */ 911 | public function testDivide($a, $b, int $scale, string $expected): void 912 | { 913 | $decimal = Decimal::create($a); 914 | $this->assertSame($expected, (string)$decimal->divide($b, $scale)); 915 | } 916 | 917 | /** 918 | * @return array 919 | */ 920 | public function divisionProvider(): array 921 | { 922 | return [ 923 | ['0', '1', 0, '0'], 924 | ['1', '1', 0, '1'], 925 | ['0', '1e6', 0, '0'], 926 | [1, 10, 1, '0.1'], 927 | ['1000', '10', 0, '100'], 928 | ['-10', '10', 0, '-1'], 929 | ['10', '-10', 0, '-1'], 930 | ['10', '10', 0, '1'], 931 | ['0.1', '1', 1, '0.1'], 932 | ['0.1', '0.01', 0, '10'], 933 | ['-0.001', '0.01', 1, '-0.1'], 934 | ['1', '3', 3, '0.333'], 935 | ['1', '3', 0, '0'], 936 | ['15', '2', 1, '7.5'], 937 | ['15', '2', 1, '7.5'], 938 | ['101', '11', 3, '9.181'], 939 | ['10', '3', 3, '3.333'], 940 | ['1.1', '.2', 3, '5.500'], 941 | ['1.23', '.2', 3, '6.150'], 942 | ['0.2', '.11111', 20, '1.80001800018000180001'], 943 | ['6.22e23', '2', 0, '311000000000000000000000'], 944 | ['6.22e23', '-1', 0, '-622000000000000000000000'], 945 | ['1e-10', 3, 0, '0'], 946 | ['1e-10', 3, 11, '0.00000000003'], 947 | ['1e-10', 3, 12, '0.000000000033'], 948 | ]; 949 | } 950 | 951 | /** 952 | * @return void 953 | */ 954 | public function testDivideByZero(): void 955 | { 956 | $decimal = Decimal::create(1); 957 | 958 | $this->expectException(DivisionByZeroError::class); 959 | 960 | $decimal->divide(0, 10); 961 | } 962 | 963 | /** 964 | * @return void 965 | */ 966 | public function testDebugInfo(): void 967 | { 968 | $value = '1.1'; 969 | $decimal = Decimal::create($value); 970 | 971 | $result = $decimal->__debugInfo(); 972 | $expected = [ 973 | 'value' => $value, 974 | 'scale' => 1, 975 | ]; 976 | $this->assertEquals($expected, $result); 977 | } 978 | 979 | /** 980 | * @return void 981 | */ 982 | public function testPrecisionLossProtection(): void 983 | { 984 | $a = Decimal::create('0.1', 50); 985 | $this->assertSame(50, $a->scale()); 986 | 987 | $b = Decimal::create($a); 988 | $this->assertSame(50, $b->scale()); 989 | 990 | $c = Decimal::create($b, 6); // Not 50 if manually overwritten 991 | $this->assertSame(6, $c->scale()); 992 | 993 | $d = Decimal::create($c, 64); 994 | $this->assertSame(64, $d->scale()); 995 | } 996 | 997 | /** 998 | * @return void 999 | */ 1000 | public function testPrecisionLossFail(): void 1001 | { 1002 | $this->expectException(InvalidArgumentException::class); 1003 | $this->expectExceptionMessage('Loss of precision detected'); 1004 | 1005 | Decimal::create('0.123', 2); 1006 | } 1007 | } 1008 | --------------------------------------------------------------------------------