From d8fc592e2c29dd50bafac79f9a6f9b9deca8243d Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 16:26:54 +0200 Subject: [PATCH 1/4] Fix parsing, negative zero, rounding and scientific notation edge cases Parsing - Leading zeros survived in several forms: "0010", "-007", "+007" and "-00" kept them, and "+.5" lost its leading zero. - Scientific notation was wrong for any mantissa with more than one integer digit and a negative exponent ("12.5e-1" became 0.125, "100e-2" became 0.0100) and for mantissas below one with a positive exponent (".5e1" became 5.5, "0.00012e3" stayed 0.00012). - An explicit plus in the exponent ("1e+5") was rejected. PHP renders large floats that way, so new Decimal(1e25) threw. - "1.0e0" dropped the written zero. A trailing ".0" is now only removed from the string PHP produces for a float. - INF and NAN were accepted and stored as the strings "INF" and "NAN". - A scale of 0 was treated as "no scale": new Decimal('1.5', 0) kept the fraction and strict mode did not throw. Negative scales were accepted. Negative zero - "-0.0", negate() on zero, truncate() and trim() of small negative values produced "-0" and reported isNegative() as true while sign() returned 0. Zero now never carries a sign. Rounding - floor() and ceil() moved whole numbers with a fraction of zeros: floor('-1.0') returned -2 and ceil('1.0') returned 2. - ROUND_FLOOR and ROUND_CEIL ignored the requested scale. - An unknown round mode silently fell back to half up. It now throws, as does a negative scale. Conversion - toScientific() returned ".e-1" for zero, went through float division and so lost digits beyond 15 for large integers, and returned "1.250e2" for 120.50. - toInt() refused PHP_INT_MIN. Operands - mod() and pow() passed the operand to bcmath unparsed, so values in scientific notation failed. mod() now defaults to the larger scale of both operands, like add(); 10 mod 0.3 returned 0 before. --- src/Decimal.php | 202 +++++++++++++++---------------- tests/DecimalTest.php | 276 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+), 106 deletions(-) diff --git a/src/Decimal.php b/src/Decimal.php index 8f005ad..ec10482 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -74,6 +74,7 @@ public function __construct(object|string|float|int $value, ?int $scale = null, $this->setValue($value, $scale); $this->setScale($scale, $strict); + $this->normalizeZero(); } /** @@ -105,11 +106,16 @@ protected function parseValue(object|string|float|int $value): string throw new InvalidArgumentException('Invalid non numeric value'); } - if (!is_string($value)) { - $value = (string)$value; + if (is_float($value)) { + if (!is_finite($value)) { + throw new InvalidArgumentException('Invalid non finite value'); + } + + // PHP renders large and small floats as `1.0E+25`, that zero is not a significant digit. + return str_replace('.0E', 'E', (string)$value); } - return $value; + return (string)$value; } /** @@ -120,22 +126,13 @@ protected function parseValue(object|string|float|int $value): string protected function normalizeValue(string $value): string { $value = trim($value); - /** @var string $value */ - $value = preg_replace( - [ - '/^^(-?)(\.)(.*)$/', // omitted leading zero - '/^0+(.)(\..*)?$/', // multiple leading zeros - '/^(\+(.*)|(-)(0))$/', // leading positive sign, tolerate minus zero too - ], - [ - '${1}0.${3}', - '${1}${2}', - '${4}${2}', - ], - $value, - ); + $sign = str_starts_with($value, '-') ? '-' : ''; + $value = ltrim(ltrim($value, '+-'), '0'); + if ($value === '' || !ctype_digit($value[0])) { + $value = '0' . $value; + } - return $value; + return $sign . $value; } /** @@ -441,7 +438,7 @@ public function pow(self|string|int $exponent, ?int $scale = null): static $scale = $this->scale(); } - return new static(bcpow((string)$this, (string)$exponent, $scale)); + return new static(bcpow((string)$this, (string)static::create($exponent), $scale)); } /** @@ -470,61 +467,52 @@ public function sqrt(?int $scale = null): static */ public function mod(self|string|int $value, ?int $scale = null): static { - if ($scale === null) { - $scale = $this->scale(); - } + $decimal = static::create($value); + $scale = $this->resultScale($this, $decimal, $scale); - return new static(bcmod((string)$this, (string)$value, $scale)); + return new static(bcmod((string)$this, (string)$decimal, $scale)); } /** - * @param int $scale Decimal places in the result (only used with mode "half up") - * @param int $roundMode + * @param int $scale Decimal places in the result + * @param int $roundMode One of the ROUND_* constants of this class + * + * @throws \InvalidArgumentException * * @return static */ public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP): static { - $exponent = $scale + 1; + if ($scale < 0) { + throw new InvalidArgumentException('Scale must be >= 0.'); + } + if (!in_array($roundMode, [static::ROUND_HALF_UP, static::ROUND_CEIL, static::ROUND_FLOOR], true)) { + throw new InvalidArgumentException('Unsupported round mode `' . $roundMode . '`.'); + } - switch ($roundMode) { - case static::ROUND_FLOOR: - $stringValue = (string)$this; + $value = (string)$this; + $truncated = bcadd($value, '0', $scale); + if (bccomp($value, $truncated, $this->scale()) === 0) { + return new static($truncated, $scale); + } - // If already an integer, return as is - if (!str_contains($stringValue, '.')) { - return new static($stringValue, $scale); - } + $unit = $scale > 0 ? '0.' . str_repeat('0', $scale - 1) . '1' : '1'; - // For positive values, truncate the decimal part (round down) - if (!$this->isNegative()) { - return new static(bcsub($stringValue, bcmod($stringValue, '1'), 0), $scale); - } + switch ($roundMode) { + case static::ROUND_FLOOR: + $result = $this->isNegative() ? bcsub($truncated, $unit, $scale) : $truncated; - // For negative values, round down away from zero - return new static(bcsub($stringValue, '1', 0), $scale); + break; case static::ROUND_CEIL: - $stringValue = (string)$this; - - // If already an integer, return as is - if (!str_contains($stringValue, '.')) { - return new static($stringValue, $scale); - } - - // If negative, truncate (remove decimals without adding 1) - if ($this->isNegative()) { - return new static(bcsub($stringValue, '0', 0), $scale); - } + $result = $this->isNegative() ? $truncated : bcadd($truncated, $unit, $scale); - // Otherwise, round up for positive numbers - return new static(bcadd($stringValue, '1', 0), $scale); - case static::ROUND_HALF_UP: + break; default: - $e = bcpow('10', (string)$exponent); - $v = bcdiv(bcadd(bcmul((string)$this, $e, 0), $this->isNegative() ? '-5' : '5'), $e, $scale); + $e = bcpow('10', (string)($scale + 1)); + $result = bcdiv(bcadd(bcmul($value, $e, 0), $this->isNegative() ? '-5' : '5'), $e, $scale); } - return new static($v, $scale); + return new static($result, $scale); } /** @@ -610,7 +598,9 @@ public function toInt(): int */ public function isBigInteger(): bool { - return bccomp($this->integralPart, (string)PHP_INT_MAX) === 1 || bccomp($this->integralPart, (string)PHP_INT_MIN) === -1; + $value = ($this->negative ? '-' : '') . $this->integralPart; + + return bccomp($value, (string)PHP_INT_MAX) === 1 || bccomp($value, (string)PHP_INT_MIN) === -1; } /** @@ -618,8 +608,7 @@ public function isBigInteger(): bool */ public function isBigDecimal(): bool { - return $this->isBigInteger() || - bccomp($this->fractionalPart, (string)PHP_INT_MAX) === 1 || bccomp($this->fractionalPart, (string)PHP_INT_MIN) === -1; + return $this->isBigInteger() || bccomp($this->fractionalPart ?: '0', (string)PHP_INT_MAX) === 1; } /** @@ -635,38 +624,31 @@ public function isBigDecimal(): bool */ public function toScientific(): string { - if ($this->integralPart) { - $exponent = 0; - $integralPart = $this->integralPart; - while ($integralPart >= 10) { - $integralPart /= 10; - $exponent++; - } + if ($this->isZero()) { + return $this->toString() . static::EXP_MARK . '0'; + } - $value = (string)$integralPart; - if (!str_contains($value, '.')) { - $value .= '.'; + if ($this->integralPart !== '0') { + $exponent = strlen($this->integralPart) - 1; + $digits = $this->integralPart . $this->fractionalPart; + if ($this->fractionalPart === '') { + $digits = rtrim($digits, '0'); } - $value .= $this->fractionalPart; } else { - $exponent = -1; - // 00002 - // 20000 - $fractionalPart = $this->fractionalPart; - while (str_starts_with($fractionalPart, '0')) { - $fractionalPart = substr($fractionalPart, 1); - $exponent--; - } + $digits = ltrim($this->fractionalPart, '0'); + $exponent = strlen($digits) - strlen($this->fractionalPart) - 1; + } - $pos = abs($exponent) - 1; - $value = substr($this->fractionalPart, $pos, 1) . '.' . substr($this->fractionalPart, $pos + 1); + $value = $digits[0]; + if (strlen($digits) > 1) { + $value .= static::RADIX_MARK . substr($digits, 1); } if ($this->negative) { $value = '-' . $value; } - return $value . 'e' . $exponent; + return $value . static::EXP_MARK . $exponent; } /** @@ -744,6 +726,7 @@ protected function copy(?string $integerPart = null, ?string $decimalPart = null if ($negative !== null) { $clone->negative = $negative; } + $clone->normalizeZero(); return $clone; } @@ -824,39 +807,30 @@ protected function fromFloat(string $value): void */ protected function fromScientific(string $value, ?int $scale): void { - $pattern = '/^(-?)(\d+(?:' . static::RADIX_MARK . '\d*)?|' . - '[' . static::RADIX_MARK . ']' . '\d+)' . static::EXP_MARK . '(-?\d*)?$/i'; - preg_match($pattern, $value, $matches); - if (!$matches) { + $pattern = '/^(-?)(\d+)(?:' . preg_quote(static::RADIX_MARK, '/') . '(\d*))?' . static::EXP_MARK . '([+-]?\d+)$/i'; + if (preg_match($pattern, $value, $matches) !== 1) { throw new InvalidArgumentException('Invalid scientific value/notation: ' . $value); } $this->negative = $matches[1] === '-'; - /** @var string $value */ - $value = preg_replace('/\b\.0$/', '', $matches[2]); - $exp = (int)$matches[3]; + $integralPart = $matches[2]; + $fractionalPart = $matches[3]; + $exp = (int)$matches[4]; if ($exp < 0) { - $this->integralPart = '0'; - /** @var string $value */ - $value = preg_replace('/^(\d+)(\.)?(\d+)$/', '${1}${3}', $value, 1); - $this->fractionalPart = str_repeat('0', -$exp - 1) . $value; - - if ($scale !== null) { - $this->fractionalPart = str_pad($this->fractionalPart, $scale, '0'); - } + $integralPart = str_pad($integralPart, -$exp + 1, '0', STR_PAD_LEFT); + $fractionalPart = substr($integralPart, $exp) . $fractionalPart; + $integralPart = substr($integralPart, 0, $exp); } else { - $this->integralPart = bcmul($matches[2], bcpow('10', (string)$exp)); + $integralPart .= substr(str_pad($fractionalPart, $exp, '0'), 0, $exp); + $fractionalPart = substr($fractionalPart, $exp); + } - $pos = strlen($this->integralPart); - if (str_contains($value, '.')) { - $pos++; - } - $this->fractionalPart = rtrim(substr($value, $pos), '.'); + $this->integralPart = ltrim($integralPart, '0') ?: '0'; + $this->fractionalPart = $fractionalPart; - if ($scale !== null) { - $this->fractionalPart = str_pad($this->fractionalPart, $scale - strlen($this->integralPart), '0'); - } + if ($scale !== null) { + $this->fractionalPart = str_pad($this->fractionalPart, $scale, '0'); } } @@ -870,8 +844,12 @@ protected function fromScientific(string $value, ?int $scale): void */ protected function setScale(?int $scale, bool $strict): void { + if ($scale !== null && $scale < 0) { + throw new InvalidArgumentException('Scale must be >= 0.'); + } + $calculatedScale = strlen($this->fractionalPart); - if ($scale && $calculatedScale > $scale) { + if ($scale !== null && $calculatedScale > $scale) { if ($strict) { throw new InvalidArgumentException('Loss of precision detected. Detected scale `' . $calculatedScale . '` > `' . $scale . '` as defined.'); } @@ -881,4 +859,16 @@ protected function setScale(?int $scale, bool $strict): void $this->scale = $scale ?? $calculatedScale; } + + /** + * Zero has no sign, `-0.00` would otherwise report as negative. + * + * @return void + */ + protected function normalizeZero(): void + { + if ($this->negative && $this->isZero()) { + $this->negative = false; + } + } } diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index 7ba553f..e235cde 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -1049,4 +1049,280 @@ public function testPrecisionLossFail(): void Decimal::create('0.123', 2, true); } + + /** + * @param mixed $value + * @param string $expected + * + * @return void + */ + #[\PHPUnit\Framework\Attributes\DataProvider('normalizationProvider')] + public function testNewObjectNormalization(mixed $value, string $expected): void + { + $this->assertSame($expected, (string)new Decimal($value)); + } + + /** + * @return array + */ + public static function normalizationProvider(): array + { + return [ + 'leading zeros' => ['0010', '10'], + 'negative leading zeros' => ['-007', '-7'], + 'positive sign leading zeros' => ['+007', '7'], + 'positive sign omitted zero' => ['+.5', '0.5'], + 'negative zero with decimals' => ['-0.0', '0.0'], + 'negative double zero' => ['-00', '0'], + 'trailing radix' => ['5.', '5'], + 'explicit positive exponent' => ['1e+5', '100000'], + 'uppercase exponent' => ['1E5', '100000'], + 'multi digit mantissa' => ['12.5e-1', '1.25'], + 'integer mantissa' => ['100e-2', '1.00'], + 'integer mantissa trailing zero' => ['120e-1', '12.0'], + 'long mantissa' => ['123.456e-2', '1.23456'], + 'negative mantissa' => ['-1.5e-2', '-0.015'], + 'omitted zero mantissa' => ['.5e1', '5'], + 'small mantissa' => ['0.00012e3', '0.12'], + 'significant zero in mantissa' => ['1.0e0', '1.0'], + 'zero exponent' => ['1.5e0', '1.5'], + 'negative zero exponent' => ['-0e5', '0'], + 'zero mantissa' => ['0.0e-3', '0.0000'], + 'large float' => [1e25, '10000000000000000000000000'], + 'small float' => [1.5e-7, '0.00000015'], + 'negative zero float' => [-0.0, '0'], + ]; + } + + /** + * @param float $value + * + * @return void + */ + #[\PHPUnit\Framework\Attributes\DataProvider('nonFiniteProvider')] + public function testNewObjectNonFiniteFloat(float $value): void + { + $this->expectException(InvalidArgumentException::class); + + new Decimal($value); + } + + /** + * @return array + */ + public static function nonFiniteProvider(): array + { + return [ + 'inf' => [INF], + 'negative inf' => [-INF], + 'nan' => [NAN], + ]; + } + + /** + * @return void + */ + public function testNewObjectZeroScale(): void + { + $decimal = new Decimal('1.56', 0); + $this->assertSame('1', (string)$decimal); + $this->assertSame(0, $decimal->scale()); + } + + /** + * @return void + */ + public function testNewObjectZeroScaleStrict(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Loss of precision detected'); + + new Decimal('1.56', 0, true); + } + + /** + * @return void + */ + public function testNewObjectNegativeScale(): void + { + $this->expectException(InvalidArgumentException::class); + + new Decimal('1.56', -1); + } + + /** + * @return void + */ + public function testNewObjectScientificWithScale(): void + { + $this->assertSame('1.500', (string)new Decimal('1.5e0', 3)); + $this->assertSame('0.001200', (string)new Decimal('1.2e-3', 6)); + $this->assertSame('0.0012', (string)new Decimal('1.23456e-3', 4)); + } + + /** + * @return void + */ + public function testNoNegativeZero(): void + { + $decimal = new Decimal('-0.0'); + $this->assertFalse($decimal->isNegative()); + $this->assertSame(0, $decimal->sign()); + + $this->assertSame('0', (string)(new Decimal('0'))->negate()); + $this->assertSame('0.00', (string)(new Decimal('0.00'))->negate()); + $this->assertSame('0', (string)(new Decimal('-0.5'))->truncate()); + $this->assertSame('0.0', (string)(new Decimal('-0.05'))->truncate(1)); + $this->assertSame('0.00', (string)new Decimal('-0.001', 2)); + $this->assertFalse((new Decimal('-0.5'))->truncate()->isNegative()); + } + + /** + * @param string $value + * @param int $scale + * @param int $mode + * @param string $expected + * + * @return void + */ + #[\PHPUnit\Framework\Attributes\DataProvider('roundModeProvider')] + public function testRoundMode(string $value, int $scale, int $mode, string $expected): void + { + $this->assertSame($expected, (string)(new Decimal($value))->round($scale, $mode)); + } + + /** + * @return array + */ + public static function roundModeProvider(): array + { + return [ + 'floor negative whole' => ['-1.0', 0, Decimal::ROUND_FLOOR, '-1'], + 'floor positive whole' => ['1.00', 0, Decimal::ROUND_FLOOR, '1'], + 'floor negative' => ['-1.5', 0, Decimal::ROUND_FLOOR, '-2'], + 'floor scale' => ['1.25', 1, Decimal::ROUND_FLOOR, '1.2'], + 'floor negative scale' => ['-1.21', 1, Decimal::ROUND_FLOOR, '-1.3'], + 'floor exact scale' => ['-1.20', 1, Decimal::ROUND_FLOOR, '-1.2'], + 'floor higher scale' => ['1.5', 3, Decimal::ROUND_FLOOR, '1.500'], + 'floor small negative' => ['-0.001', 2, Decimal::ROUND_FLOOR, '-0.01'], + 'ceil positive whole' => ['1.0', 0, Decimal::ROUND_CEIL, '1'], + 'ceil negative whole' => ['-1.00', 0, Decimal::ROUND_CEIL, '-1'], + 'ceil positive' => ['1.5', 0, Decimal::ROUND_CEIL, '2'], + 'ceil scale' => ['1.21', 1, Decimal::ROUND_CEIL, '1.3'], + 'ceil negative scale' => ['-1.29', 1, Decimal::ROUND_CEIL, '-1.2'], + 'ceil exact scale' => ['1.20', 1, Decimal::ROUND_CEIL, '1.2'], + 'ceil small negative' => ['-0.001', 2, Decimal::ROUND_CEIL, '0.00'], + 'ceil small positive' => ['0.001', 2, Decimal::ROUND_CEIL, '0.01'], + 'half up negative' => ['-1.005', 2, Decimal::ROUND_HALF_UP, '-1.01'], + 'half up to zero' => ['-0.004', 2, Decimal::ROUND_HALF_UP, '0.00'], + ]; + } + + /** + * @return void + */ + public function testRoundNegativeScale(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('15'))->round(-1); + } + + /** + * @param string $value + * + * @return void + */ + #[\PHPUnit\Framework\Attributes\DataProvider('roundInvalidModeProvider')] + public function testRoundInvalidMode(string $value): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal($value))->round(0, PHP_ROUND_HALF_EVEN); + } + + /** + * @return array + */ + public static function roundInvalidModeProvider(): array + { + return [ + 'needs rounding' => ['1.5'], + 'exact' => ['1.0'], + ]; + } + + /** + * @param string $value + * @param string $expected + * + * @return void + */ + #[\PHPUnit\Framework\Attributes\DataProvider('scientificEdgeProvider')] + public function testToScientificEdgeCases(string $value, string $expected): void + { + $decimal = new Decimal($value); + $this->assertSame($expected, $decimal->toScientific()); + $this->assertSame($value, (string)new Decimal($decimal->toScientific())); + } + + /** + * @return array + */ + public static function scientificEdgeProvider(): array + { + return [ + 'zero' => ['0', '0e0'], + 'zero with scale' => ['0.00', '0.00e0'], + 'single digit' => ['5', '5e0'], + 'power of ten' => ['10', '1e1'], + 'large power of ten' => ['100000', '1e5'], + 'trailing zero integer' => ['120', '1.2e2'], + 'trailing zero integer with fraction' => ['120.50', '1.2050e2'], + 'beyond float precision' => ['1234567890123456789012.5', '1.2345678901234567890125e21'], + 'beyond int precision' => ['12345678901234567', '1.2345678901234567e16'], + 'single fraction digit' => ['0.5', '5e-1'], + 'small fraction' => ['0.00012', '1.2e-4'], + 'negative with scale' => ['-12.50', '-1.250e1'], + ]; + } + + /** + * @return void + */ + public function testToIntBoundaries(): void + { + $this->assertSame(PHP_INT_MIN, (new Decimal((string)PHP_INT_MIN))->toInt()); + $this->assertSame(PHP_INT_MAX, (new Decimal((string)PHP_INT_MAX))->toInt()); + $this->assertFalse((new Decimal((string)PHP_INT_MIN))->isBigInteger()); + $this->assertTrue((new Decimal('-9223372036854775809'))->isBigInteger()); + $this->assertTrue((new Decimal('9223372036854775808'))->isBigInteger()); + } + + /** + * @return void + */ + public function testModOperands(): void + { + $decimal = new Decimal('10'); + + $this->assertSame('0', (string)$decimal->mod('1e1')); + $this->assertSame('1', (string)$decimal->mod(new Decimal('3'))); + $this->assertSame('0.1', (string)$decimal->mod('0.3')); + $this->assertSame('1.5', (string)(new Decimal('10.5'))->mod('3')); + $this->assertSame('0', (string)$decimal->mod('0.3', 0)); + } + + /** + * @return void + */ + public function testPowOperands(): void + { + $decimal = new Decimal('2'); + + $this->assertSame('100', (string)(new Decimal('10'))->pow('2e0')); + $this->assertSame('4', (string)$decimal->pow(new Decimal('2'))); + $this->assertSame('0.50', (string)$decimal->pow(-1, 2)); + $this->assertSame('2.25', (string)(new Decimal('1.50'))->pow(2)); + } } From a02decde41ff0471a659b25e3c602cbe5aff18af Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 16:31:53 +0200 Subject: [PATCH 2/4] Require PHP 8.2 and update the dev tooling (#21) PHP 8.1 has been out of security support since the end of 2025. The floor stops at 8.2 because dereuromark/cakephp-decimal supports 8.2 and should keep receiving new releases of this library. - PHPUnit ^11.5 || ^12 || ^13, the 10.x line was only needed for 8.1. - php-collective/code-sniffer 0.2 to 0.6. The new rules want attributes imported and docblock types that agree with the native hint, so the operand docblocks now say self like the signatures do. - CI runs 8.2, 8.3, 8.4 and 8.5 on ubuntu-latest. - The Docker image no longer downloads PHPUnit 10.4 and PHPStan 1.10 phars. The Makefile runs the Composer scripts and never used them. - composer.json gains keywords and support links. --- .docker/Dockerfile | 9 +------ .env | 4 +-- .github/workflows/ci.yml | 18 ++++++------- README.md | 5 ++-- compose.yaml | 4 +-- composer.json | 18 ++++++++++--- phpunit.xml.dist | 2 ++ src/Decimal.php | 30 +++++++++++----------- tests/DecimalTest.php | 55 ++++++++++++++++++++-------------------- 9 files changed, 75 insertions(+), 70 deletions(-) diff --git a/.docker/Dockerfile b/.docker/Dockerfile index dd46470..d7bc22c 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -1,14 +1,7 @@ -ARG PHP_VERSION=8.2 -ARG PHPUNIT_VERSION=10.4.0 -ARG PHPSTAN_VERSION=1.10.38 +ARG PHP_VERSION=8.5 FROM php:${PHP_VERSION}-cli-alpine COPY --from=composer /usr/bin/composer /usr/bin/composer -ARG PHPUNIT_VERSION -ARG PHPSTAN_VERSION - RUN docker-php-ext-install bcmath && docker-php-ext-enable bcmath -RUN wget -O phpunit https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar && chmod +x phpunit -RUN wget -O phpstan https://github.com/phpstan/phpstan/releases/download/${PHPSTAN_VERSION}/phpstan.phar && chmod +x phpstan diff --git a/.env b/.env index af97ce2..93ca64f 100644 --- a/.env +++ b/.env @@ -1,3 +1 @@ -PHP_VERSION=8.2 -PHPUNIT_VERSION=10.4.0 -PHPSTAN_VERSION=1.10.38 +PHP_VERSION=8.5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 191fdc4..e40c7f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,14 +9,14 @@ on: jobs: testsuite: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php-version: ['8.1', '8.3', '8.5'] + php-version: ['8.2', '8.3', '8.4', '8.5'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -32,29 +32,29 @@ jobs: - name: Run PHPUnit run: | - if [[ ${{ matrix.php-version }} == '8.1' ]]; then + if [[ ${{ matrix.php-version }} == '8.2' ]]; then vendor/bin/phpunit --coverage-clover=coverage.xml else vendor/bin/phpunit fi - name: Code Coverage Report - if: success() && matrix.php-version == '8.1' - uses: codecov/codecov-action@v4 + if: success() && matrix.php-version == '8.2' + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} validation: name: Coding Standard & Static Analysis - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' extensions: mbstring, intl, bcmath coverage: none diff --git a/README.md b/README.md index 518f735..9193433 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/php-collective/decimal-object/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/php-collective/decimal-object/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/php-collective/decimal-object/branch/master/graph/badge.svg?token=L1thFB9nOG)](https://codecov.io/gh/php-collective/decimal-object) [![Latest Stable Version](https://poser.pugx.org/php-collective/decimal-object/v/stable.svg)](https://packagist.org/packages/php-collective/decimal-object) -[![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.1-8892BF.svg)](https://php.net/) +[![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.2-8892BF.svg)](https://php.net/) [![PHPStan](https://img.shields.io/badge/PHPStan-level%208-brightgreen.svg?style=flat)](https://phpstan.org/) [![License](https://poser.pugx.org/php-collective/decimal-object/license)](https://packagist.org/packages/php-collective/decimal-object) @@ -26,7 +26,7 @@ Monetary values themselves could be handled also with [brick/money](https://gith For details see the [wiki](https://github.com/php-collective/decimal-object/wiki). -Note: If you are looking for pre PHP 8.1 versions, check out the [legacy library](https://github.com/spryker/decimal-object). +Note: PHP 8.1 is covered by the 1.3 releases. For anything older, check out the [legacy library](https://github.com/spryker/decimal-object). ## Features @@ -40,6 +40,7 @@ Note: If you are looking for pre PHP 8.1 versions, check out the [legacy library ### Requirements +- PHP 8.2+ - `bcmath` PHP extension enabled ### Composer (preferred) diff --git a/compose.yaml b/compose.yaml index 84edd30..7d9c008 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,9 +6,7 @@ services: context: . dockerfile: .docker/Dockerfile args: - PHP_VERSION: "${PHP_VERSION:-8.2}" - PHPUNIT_VERSION: "${PHPUNIT_VERSION:-10.4.0}" - PHPSTAN_VERSION: "${PHPSTAN_VERSION:-1.10.38}" + PHP_VERSION: "${PHP_VERSION:-8.5}" volumes: - ./:/code working_dir: /code diff --git a/composer.json b/composer.json index ae80adf..c37719e 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,14 @@ "description": "PHP decimal handling as value object", "license": "MIT", "type": "library", + "keywords": [ + "decimal", + "value-object", + "bcmath", + "precision", + "arbitrary-precision", + "math" + ], "authors": [ { "name": "The PHP Collective", @@ -13,15 +21,19 @@ "homepage": "https://spryker.com" } ], + "support": { + "issues": "https://github.com/php-collective/decimal-object/issues", + "source": "https://github.com/php-collective/decimal-object" + }, "require": { - "php": ">=8.1", + "php": ">=8.2", "ext-bcmath": "*", "ext-json": "*" }, "require-dev": { - "php-collective/code-sniffer": "^0.2.1", + "php-collective/code-sniffer": "^0.6.8", "phpstan/phpstan": "^2.0.0", - "phpunit/phpunit": "^10.5.45 || ^11.0.0 || ^12.0.0 || ^13.0.0" + "phpunit/phpunit": "^11.5.0 || ^12.0.0 || ^13.0.0" }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ef3d67a..eca8d3f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,7 @@ diff --git a/src/Decimal.php b/src/Decimal.php index ec10482..e3cba1a 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -162,7 +162,7 @@ public static function create(object|string|float|int $value, ?int $scale = null * * This method is equivalent to the `==` operator. * - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool TRUE if this decimal is considered equal to the given value. * Equal decimal values tie-break on precision. @@ -173,7 +173,7 @@ public function equals(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -183,7 +183,7 @@ public function greaterThan(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -193,7 +193,7 @@ public function lessThan(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -205,7 +205,7 @@ public function greaterThanOrEquals(self|string|float|int $value): bool /** * @deprecated Use {@link greaterThanOrEquals()} instead. * - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -215,7 +215,7 @@ public function greatherThanOrEquals(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -232,7 +232,7 @@ public function lessThanOrEquals(self|string|float|int $value): bool * - `0` if the instance is equal to $value, or * - `1` if the instance is greater than $value. * - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return int */ @@ -247,7 +247,7 @@ public function compareTo(self|string|float|int $value): int /** * Add $value to this Decimal and return the sum as a new Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static @@ -281,7 +281,7 @@ protected function resultScale(self $a, self $b, ?int $scale = null): int * Subtract $value from this Decimal and return the difference as a new * Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static @@ -389,12 +389,12 @@ public function isPositive(): bool /** * Multiply this Decimal by $value and return the product as a new Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ - public function multiply(self|string|int|float $value, ?int $scale = null): static + public function multiply(self|string|float|int $value, ?int $scale = null): static { $decimal = static::create($value); if ($scale === null) { @@ -407,14 +407,14 @@ public function multiply(self|string|int|float $value, ?int $scale = null): stat /** * Divide this Decimal by $value and return the quotient as a new Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int $scale Decimal places in the result * * @throws \DivisionByZeroError if $value is zero. * * @return static */ - public function divide(self|string|int|float $value, int $scale): static + public function divide(self|string|float|int $value, int $scale): static { $decimal = static::create($value); if ($decimal->isZero()) { @@ -427,7 +427,7 @@ public function divide(self|string|int|float $value, int $scale): static /** * This method is equivalent to the ** operator. * - * @param static|string|int $exponent + * @param self|string|int $exponent * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static @@ -460,7 +460,7 @@ public function sqrt(?int $scale = null): static /** * This method is equivalent to the % operator. * - * @param static|string|int $value + * @param self|string|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index e235cde..bf863c8 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -12,6 +12,7 @@ use DivisionByZeroError; use InvalidArgumentException; use PhpCollective\DecimalObject\Decimal; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Stringable; use TypeError; @@ -24,7 +25,7 @@ class DecimalTest extends TestCase * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('baseProvider')] + #[DataProvider('baseProvider')] public function testNewObject(mixed $value, string $expected): void { $decimal = new Decimal($value); @@ -50,7 +51,7 @@ public function testNewObjectScientific(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('baseProvider')] + #[DataProvider('baseProvider')] public function testCreate(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -142,7 +143,7 @@ public function __toString(): string * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('invalidValuesProvider')] + #[DataProvider('invalidValuesProvider')] public function testNewObjectWithInvalidValueThrowsException(mixed $value): void { $this->expectException(InvalidArgumentException::class); @@ -169,7 +170,7 @@ public static function invalidValuesProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('truncateProvider')] + #[DataProvider('truncateProvider')] public function testTruncate(mixed $input, int $scale, string $expected): void { $decimal = Decimal::create($input); @@ -197,7 +198,7 @@ public static function truncateProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('integerProvider')] + #[DataProvider('integerProvider')] public function testIsInteger(mixed $value, bool $expected): void { $decimal = Decimal::create($value); @@ -229,7 +230,7 @@ public static function integerProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('zeroProvider')] + #[DataProvider('zeroProvider')] public function testIsZero(mixed $value, bool $expected): void { $decimal = Decimal::create($value); @@ -260,7 +261,7 @@ public static function zeroProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareZeroProvider')] + #[DataProvider('compareZeroProvider')] public function testIsPositive(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -273,7 +274,7 @@ public function testIsPositive(mixed $input, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareZeroProvider')] + #[DataProvider('compareZeroProvider')] public function testIsNegative(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -307,7 +308,7 @@ public static function compareZeroProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('scaleProvider')] + #[DataProvider('scaleProvider')] public function testScale(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -338,7 +339,7 @@ public static function scaleProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('scientificProvider')] + #[DataProvider('scientificProvider')] public function testToScientific(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -411,7 +412,7 @@ public function testToFloat(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('bigFloatDataProvider')] + #[DataProvider('bigFloatDataProvider')] public function testToFloatForBigDecimalThrowsAnException(string $value): void { $decimal = Decimal::create($value); @@ -450,7 +451,7 @@ public function testToInt(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('bigIntDataProvider')] + #[DataProvider('bigIntDataProvider')] public function testToIntForBigIntThrowsAnException(string $value): void { $decimal = Decimal::create($value); @@ -605,7 +606,7 @@ public function testEquals(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('roundProvider')] + #[DataProvider('roundProvider')] public function testRound(mixed $value, int $scale, string $expected): void { $decimal = Decimal::create($value); @@ -664,7 +665,7 @@ public static function roundProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('floorProvider')] + #[DataProvider('floorProvider')] public function testFloor(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -717,7 +718,7 @@ public static function floorProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('ceilProvider')] + #[DataProvider('ceilProvider')] public function testCeil(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -770,7 +771,7 @@ public static function ceilProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testGreaterThan(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -784,7 +785,7 @@ public function testGreaterThan(mixed $a, mixed $b, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testLessThan(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -798,7 +799,7 @@ public function testLessThan(mixed $a, mixed $b, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testGreaterEquals(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -812,7 +813,7 @@ public function testGreaterEquals(mixed $a, mixed $b, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testLessEquals(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -876,7 +877,7 @@ public function testSubtract(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('multiplicationProvider')] + #[DataProvider('multiplicationProvider')] public function testMultiply(mixed $a, mixed $b, ?int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -915,7 +916,7 @@ public static function multiplicationProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('multiplicationLegacyProvider')] + #[DataProvider('multiplicationLegacyProvider')] public function testMultiplyLegacy(mixed $a, mixed $b, ?int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -940,7 +941,7 @@ public static function multiplicationLegacyProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('divisionProvider')] + #[DataProvider('divisionProvider')] public function testDivide(mixed $a, mixed $b, int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -1056,7 +1057,7 @@ public function testPrecisionLossFail(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('normalizationProvider')] + #[DataProvider('normalizationProvider')] public function testNewObjectNormalization(mixed $value, string $expected): void { $this->assertSame($expected, (string)new Decimal($value)); @@ -1099,7 +1100,7 @@ public static function normalizationProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('nonFiniteProvider')] + #[DataProvider('nonFiniteProvider')] public function testNewObjectNonFiniteFloat(float $value): void { $this->expectException(InvalidArgumentException::class); @@ -1185,7 +1186,7 @@ public function testNoNegativeZero(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('roundModeProvider')] + #[DataProvider('roundModeProvider')] public function testRoundMode(string $value, int $scale, int $mode, string $expected): void { $this->assertSame($expected, (string)(new Decimal($value))->round($scale, $mode)); @@ -1233,7 +1234,7 @@ public function testRoundNegativeScale(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('roundInvalidModeProvider')] + #[DataProvider('roundInvalidModeProvider')] public function testRoundInvalidMode(string $value): void { $this->expectException(InvalidArgumentException::class); @@ -1258,7 +1259,7 @@ public static function roundInvalidModeProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('scientificEdgeProvider')] + #[DataProvider('scientificEdgeProvider')] public function testToScientificEdgeCases(string $value, string $expected): void { $decimal = new Decimal($value); From ede83d6883a8b11e40a7332a8c8a3590c6dd5702 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 16:40:01 +0200 Subject: [PATCH 3/4] Keep four behavior changes out of 1.x A run of the old and new class over the same 10,000 calls showed four of the earlier changes altering results for ordinary input, with no error to point at the cause. They move to the next major and 1.x keeps its behavior: - round() with an unsupported mode rounds half up again, where the fix made every such call throw. - ROUND_FLOOR and ROUND_CEIL round to a whole number whatever scale is passed, as the docblock has always said. - A scale of 0 in the constructor is read as no scale and does not cut off decimals. - mod() defaults to the scale of the value it is called on. The fixes for wrong results stay: floor() and ceil() on whole numbers with decimals, operand parsing in mod() and pow(), the negative scale check. --- src/Decimal.php | 29 ++++++++++---------- tests/DecimalTest.php | 62 ++++--------------------------------------- 2 files changed, 20 insertions(+), 71 deletions(-) diff --git a/src/Decimal.php b/src/Decimal.php index e3cba1a..e133d78 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -467,15 +467,16 @@ public function sqrt(?int $scale = null): static */ public function mod(self|string|int $value, ?int $scale = null): static { - $decimal = static::create($value); - $scale = $this->resultScale($this, $decimal, $scale); + if ($scale === null) { + $scale = $this->scale(); + } - return new static(bcmod((string)$this, (string)$decimal, $scale)); + return new static(bcmod((string)$this, (string)static::create($value), $scale)); } /** - * @param int $scale Decimal places in the result - * @param int $roundMode One of the ROUND_* constants of this class + * @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. * * @throws \InvalidArgumentException * @@ -486,25 +487,24 @@ public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP): sta if ($scale < 0) { throw new InvalidArgumentException('Scale must be >= 0.'); } - if (!in_array($roundMode, [static::ROUND_HALF_UP, static::ROUND_CEIL, static::ROUND_FLOOR], true)) { - throw new InvalidArgumentException('Unsupported round mode `' . $roundMode . '`.'); - } + + // 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; $value = (string)$this; - $truncated = bcadd($value, '0', $scale); + $truncated = bcadd($value, '0', $places); if (bccomp($value, $truncated, $this->scale()) === 0) { return new static($truncated, $scale); } - $unit = $scale > 0 ? '0.' . str_repeat('0', $scale - 1) . '1' : '1'; - switch ($roundMode) { case static::ROUND_FLOOR: - $result = $this->isNegative() ? bcsub($truncated, $unit, $scale) : $truncated; + $result = $this->isNegative() ? bcsub($truncated, '1', 0) : $truncated; break; case static::ROUND_CEIL: - $result = $this->isNegative() ? $truncated : bcadd($truncated, $unit, $scale); + $result = $this->isNegative() ? $truncated : bcadd($truncated, '1', 0); break; default: @@ -848,8 +848,9 @@ protected function setScale(?int $scale, bool $strict): void throw new InvalidArgumentException('Scale must be >= 0.'); } + // A scale of 0 is treated like no scale in 1.x and does not cut off decimals. $calculatedScale = strlen($this->fractionalPart); - if ($scale !== null && $calculatedScale > $scale) { + if ($scale && $calculatedScale > $scale) { if ($strict) { throw new InvalidArgumentException('Loss of precision detected. Detected scale `' . $calculatedScale . '` > `' . $scale . '` as defined.'); } diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index bf863c8..8971d59 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -1120,27 +1120,6 @@ public static function nonFiniteProvider(): array ]; } - /** - * @return void - */ - public function testNewObjectZeroScale(): void - { - $decimal = new Decimal('1.56', 0); - $this->assertSame('1', (string)$decimal); - $this->assertSame(0, $decimal->scale()); - } - - /** - * @return void - */ - public function testNewObjectZeroScaleStrict(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Loss of precision detected'); - - new Decimal('1.56', 0, true); - } - /** * @return void */ @@ -1201,19 +1180,13 @@ public static function roundModeProvider(): array 'floor negative whole' => ['-1.0', 0, Decimal::ROUND_FLOOR, '-1'], 'floor positive whole' => ['1.00', 0, Decimal::ROUND_FLOOR, '1'], 'floor negative' => ['-1.5', 0, Decimal::ROUND_FLOOR, '-2'], - 'floor scale' => ['1.25', 1, Decimal::ROUND_FLOOR, '1.2'], - 'floor negative scale' => ['-1.21', 1, Decimal::ROUND_FLOOR, '-1.3'], - 'floor exact scale' => ['-1.20', 1, Decimal::ROUND_FLOOR, '-1.2'], - 'floor higher scale' => ['1.5', 3, Decimal::ROUND_FLOOR, '1.500'], - 'floor small negative' => ['-0.001', 2, Decimal::ROUND_FLOOR, '-0.01'], 'ceil positive whole' => ['1.0', 0, Decimal::ROUND_CEIL, '1'], 'ceil negative whole' => ['-1.00', 0, Decimal::ROUND_CEIL, '-1'], 'ceil positive' => ['1.5', 0, Decimal::ROUND_CEIL, '2'], - 'ceil scale' => ['1.21', 1, Decimal::ROUND_CEIL, '1.3'], - 'ceil negative scale' => ['-1.29', 1, Decimal::ROUND_CEIL, '-1.2'], - 'ceil exact scale' => ['1.20', 1, Decimal::ROUND_CEIL, '1.2'], - 'ceil small negative' => ['-0.001', 2, Decimal::ROUND_CEIL, '0.00'], - 'ceil small positive' => ['0.001', 2, Decimal::ROUND_CEIL, '0.01'], + 'floor small negative' => ['-0.001', 0, Decimal::ROUND_FLOOR, '-1'], + 'floor zero with decimals' => ['0.00', 0, Decimal::ROUND_FLOOR, '0'], + 'ceil small positive' => ['0.001', 0, Decimal::ROUND_CEIL, '1'], + 'ceil zero with decimals' => ['0.00', 0, Decimal::ROUND_CEIL, '0'], 'half up negative' => ['-1.005', 2, Decimal::ROUND_HALF_UP, '-1.01'], 'half up to zero' => ['-0.004', 2, Decimal::ROUND_HALF_UP, '0.00'], ]; @@ -1229,30 +1202,6 @@ public function testRoundNegativeScale(): void (new Decimal('15'))->round(-1); } - /** - * @param string $value - * - * @return void - */ - #[DataProvider('roundInvalidModeProvider')] - public function testRoundInvalidMode(string $value): void - { - $this->expectException(InvalidArgumentException::class); - - (new Decimal($value))->round(0, PHP_ROUND_HALF_EVEN); - } - - /** - * @return array - */ - public static function roundInvalidModeProvider(): array - { - return [ - 'needs rounding' => ['1.5'], - 'exact' => ['1.0'], - ]; - } - /** * @param string $value * @param string $expected @@ -1309,9 +1258,8 @@ public function testModOperands(): void $this->assertSame('0', (string)$decimal->mod('1e1')); $this->assertSame('1', (string)$decimal->mod(new Decimal('3'))); - $this->assertSame('0.1', (string)$decimal->mod('0.3')); + $this->assertSame('0.1', (string)$decimal->mod('0.3', 1)); $this->assertSame('1.5', (string)(new Decimal('10.5'))->mod('3')); - $this->assertSame('0', (string)$decimal->mod('0.3', 0)); } /** From 763e5bc633cb523e193a358910ff9d3be6bc8d4a Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 16:41:43 +0200 Subject: [PATCH 4/4] Keep three more output changes out of 1.x Same reasoning as the previous commit, applied to the changes that only alter the string a caller gets back: - A string mantissa of "1.0" is read as "1" again, so "1.0e-5" keeps a scale of 5. - Scientific input with a positive exponent and a scale pads the way it did before. - toScientific() keeps the radix mark on a mantissa without decimals ("5.e0"). The wrong results stay fixed: zero, lost digits on large integers, and values such as 120.50. All three move to the next major. --- src/Decimal.php | 13 +++++++------ tests/DecimalTest.php | 12 +++++------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/Decimal.php b/src/Decimal.php index e133d78..f92677b 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -639,10 +639,8 @@ public function toScientific(): string $exponent = strlen($digits) - strlen($this->fractionalPart) - 1; } - $value = $digits[0]; - if (strlen($digits) > 1) { - $value .= static::RADIX_MARK . substr($digits, 1); - } + // The radix mark stays even without decimals (`5.e0`) in 1.x. + $value = $digits[0] . static::RADIX_MARK . substr($digits, 1); if ($this->negative) { $value = '-' . $value; @@ -814,7 +812,8 @@ protected function fromScientific(string $value, ?int $scale): void $this->negative = $matches[1] === '-'; $integralPart = $matches[2]; - $fractionalPart = $matches[3]; + // A mantissa of `1.0` is read as `1` in 1.x. + $fractionalPart = $matches[3] === '0' ? '' : $matches[3]; $exp = (int)$matches[4]; if ($exp < 0) { @@ -830,7 +829,9 @@ protected function fromScientific(string $value, ?int $scale): void $this->fractionalPart = $fractionalPart; if ($scale !== null) { - $this->fractionalPart = str_pad($this->fractionalPart, $scale, '0'); + // 1.x pads positive exponents to the scale minus the integral digits. + $length = $exp < 0 ? $scale : $scale - strlen($this->integralPart); + $this->fractionalPart = str_pad($this->fractionalPart, $length, '0'); } } diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index 8971d59..aa5ad49 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -1085,10 +1085,9 @@ public static function normalizationProvider(): array 'negative mantissa' => ['-1.5e-2', '-0.015'], 'omitted zero mantissa' => ['.5e1', '5'], 'small mantissa' => ['0.00012e3', '0.12'], - 'significant zero in mantissa' => ['1.0e0', '1.0'], 'zero exponent' => ['1.5e0', '1.5'], 'negative zero exponent' => ['-0e5', '0'], - 'zero mantissa' => ['0.0e-3', '0.0000'], + 'zero mantissa' => ['0.0e-3', '0.000'], 'large float' => [1e25, '10000000000000000000000000'], 'small float' => [1.5e-7, '0.00000015'], 'negative zero float' => [-0.0, '0'], @@ -1135,7 +1134,6 @@ public function testNewObjectNegativeScale(): void */ public function testNewObjectScientificWithScale(): void { - $this->assertSame('1.500', (string)new Decimal('1.5e0', 3)); $this->assertSame('0.001200', (string)new Decimal('1.2e-3', 6)); $this->assertSame('0.0012', (string)new Decimal('1.23456e-3', 4)); } @@ -1224,14 +1222,14 @@ public static function scientificEdgeProvider(): array return [ 'zero' => ['0', '0e0'], 'zero with scale' => ['0.00', '0.00e0'], - 'single digit' => ['5', '5e0'], - 'power of ten' => ['10', '1e1'], - 'large power of ten' => ['100000', '1e5'], + 'single digit' => ['5', '5.e0'], + 'power of ten' => ['10', '1.e1'], + 'large power of ten' => ['100000', '1.e5'], 'trailing zero integer' => ['120', '1.2e2'], 'trailing zero integer with fraction' => ['120.50', '1.2050e2'], 'beyond float precision' => ['1234567890123456789012.5', '1.2345678901234567890125e21'], 'beyond int precision' => ['12345678901234567', '1.2345678901234567e16'], - 'single fraction digit' => ['0.5', '5e-1'], + 'single fraction digit' => ['0.5', '5.e-1'], 'small fraction' => ['0.00012', '1.2e-4'], 'negative with scale' => ['-12.50', '-1.250e1'], ];