diff --git a/CHANGELOG.md b/CHANGELOG.md index 09fca42..3c7dc8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. improved type-safety when dealing with arbitrary string values. * Introduce `Math\CalculatorInterface` for representing calculators to perform arithmetic operations on integers. +* Depend on [brick/math](https://github.com/brick/math) for the + `Math\BrickMathCalculator`, which is the default calculator used by this + library when math cannot be performed in native PHP due to integer size + limitations. The calculator is configurable and may be changed, if desired. ### Changed diff --git a/composer.json b/composer.json index 4c129f5..75f4dbe 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "require": { "php": "^7.2 | ^8", "ext-json": "*", + "brick/math": "^0.8", "symfony/polyfill-ctype": "^1.8" }, "require-dev": { diff --git a/src/Math/BrickMathCalculator.php b/src/Math/BrickMathCalculator.php new file mode 100644 index 0000000..043e442 --- /dev/null +++ b/src/Math/BrickMathCalculator.php @@ -0,0 +1,138 @@ + + * @license http://opensource.org/licenses/MIT MIT + */ + +declare(strict_types=1); + +namespace Ramsey\Uuid\Math; + +use Brick\Math\BigInteger; +use Brick\Math\Exception\MathException; +use Brick\Math\RoundingMode as BrickMathRounding; +use Ramsey\Uuid\Exception\InvalidArgumentException; +use Ramsey\Uuid\Type\Hexadecimal; +use Ramsey\Uuid\Type\IntegerValue; + +/** + * A calculator using the brick/math library for arbitrary-precision arithmetic + * + * @psalm-immutable + */ +final class BrickMathCalculator implements CalculatorInterface +{ + private const ROUNDING_MODE_MAP = [ + RoundingMode::UNNECESSARY => BrickMathRounding::UNNECESSARY, + RoundingMode::UP => BrickMathRounding::UP, + RoundingMode::DOWN => BrickMathRounding::DOWN, + RoundingMode::CEILING => BrickMathRounding::CEILING, + RoundingMode::FLOOR => BrickMathRounding::FLOOR, + RoundingMode::HALF_UP => BrickMathRounding::HALF_UP, + RoundingMode::HALF_DOWN => BrickMathRounding::HALF_DOWN, + RoundingMode::HALF_CEILING => BrickMathRounding::HALF_CEILING, + RoundingMode::HALF_FLOOR => BrickMathRounding::HALF_FLOOR, + RoundingMode::HALF_EVEN => BrickMathRounding::HALF_EVEN, + ]; + + public function add(IntegerValue $augend, IntegerValue ...$addends): IntegerValue + { + /** @psalm-suppress ImpureMethodCall */ + $sum = BigInteger::of($augend->toString()); + + foreach ($addends as $addend) { + /** @psalm-suppress ImpureMethodCall */ + $sum = $sum->plus($addend->toString()); + } + + return new IntegerValue((string) $sum); + } + + public function subtract(IntegerValue $minuend, IntegerValue ...$subtrahends): IntegerValue + { + /** @psalm-suppress ImpureMethodCall */ + $difference = BigInteger::of($minuend->toString()); + + foreach ($subtrahends as $subtrahend) { + /** @psalm-suppress ImpureMethodCall */ + $difference = $difference->minus($subtrahend->toString()); + } + + return new IntegerValue((string) $difference); + } + + public function multiply(IntegerValue $multiplicand, IntegerValue ...$multipliers): IntegerValue + { + /** @psalm-suppress ImpureMethodCall */ + $product = BigInteger::of($multiplicand->toString()); + + foreach ($multipliers as $multiplier) { + /** @psalm-suppress ImpureMethodCall */ + $product = $product->multipliedBy($multiplier->toString()); + } + + return new IntegerValue((string) $product); + } + + public function divide(int $roundingMode, IntegerValue $dividend, IntegerValue ...$divisors): IntegerValue + { + $brickRounding = $this->getBrickRoundingMode($roundingMode); + + /** @psalm-suppress ImpureMethodCall */ + $quotient = BigInteger::of($dividend->toString()); + + foreach ($divisors as $divisor) { + /** @psalm-suppress ImpureMethodCall */ + $quotient = $quotient->dividedBy($divisor->toString(), $brickRounding); + } + + return new IntegerValue((string) $quotient); + } + + public function fromBase(string $value, int $base): IntegerValue + { + try { + /** @psalm-suppress ImpureMethodCall */ + return new IntegerValue((string) BigInteger::fromBase($value, $base)); + } catch (MathException $exception) { + throw new InvalidArgumentException( + $exception->getMessage(), + (int) $exception->getCode(), + $exception + ); + } + } + + public function toBase(IntegerValue $value, int $base): string + { + try { + /** @psalm-suppress ImpureMethodCall */ + return BigInteger::of($value->toString())->toBase($base); + } catch (MathException $exception) { + throw new InvalidArgumentException( + $exception->getMessage(), + (int) $exception->getCode(), + $exception + ); + } + } + + public function toHexadecimal(IntegerValue $value): Hexadecimal + { + return new Hexadecimal($this->toBase($value, 16)); + } + + /** + * Maps ramsey/uuid rounding modes to those used by brick/math + */ + private function getBrickRoundingMode(int $roundingMode): int + { + return (int) self::ROUNDING_MODE_MAP[$roundingMode] ?? 0; + } +} diff --git a/src/Math/CalculatorInterface.php b/src/Math/CalculatorInterface.php index 3a64ae2..752f051 100644 --- a/src/Math/CalculatorInterface.php +++ b/src/Math/CalculatorInterface.php @@ -55,16 +55,17 @@ interface CalculatorInterface public function multiply(IntegerValue $multiplicand, IntegerValue ...$multipliers): IntegerValue; /** - * Returns the quotient of all the provided parameters divided left-to-right + * Returns the quotient of the provided parameters divided left-to-right * + * @param int $roundingMode The RoundingMode constant to use for this operation * @param IntegerValue $dividend The integer to be divided * @param IntegerValue ...$divisors The integers to divide the dividend, in * the order in which the division operations should take place * (left-to-right) * - * @return IntegerValue The quotient of dividing all the provided parameters left-to-right + * @return IntegerValue The quotient of dividing the provided parameters left-to-right */ - public function divide(IntegerValue $dividend, IntegerValue ...$divisors): IntegerValue; + public function divide(int $roundingMode, IntegerValue $dividend, IntegerValue ...$divisors): IntegerValue; /** * Converts a value from an arbitrary base to a base-10 integer value diff --git a/src/Math/RoundingMode.php b/src/Math/RoundingMode.php new file mode 100644 index 0000000..c16e75b --- /dev/null +++ b/src/Math/RoundingMode.php @@ -0,0 +1,132 @@ += 0.5; otherwise, behaves as for DOWN. + * Note that this is the rounding mode commonly taught at school. + */ + public const HALF_UP = 5; + + /** + * Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round down. + * + * Behaves as for UP if the discarded fraction is > 0.5; otherwise, behaves as for DOWN. + */ + public const HALF_DOWN = 6; + + /** + * Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round towards positive infinity. + * + * If the result is positive, behaves as for HALF_UP; if negative, behaves as for HALF_DOWN. + */ + public const HALF_CEILING = 7; + + /** + * Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round towards negative infinity. + * + * If the result is positive, behaves as for HALF_DOWN; if negative, behaves as for HALF_UP. + */ + public const HALF_FLOOR = 8; + + /** + * Rounds towards the "nearest neighbor" unless both neighbors are equidistant, in which case rounds towards the even neighbor. + * + * Behaves as for HALF_UP if the digit to the left of the discarded fraction is odd; + * behaves as for HALF_DOWN if it's even. + * + * Note that this is the rounding mode that statistically minimizes + * cumulative error when applied repeatedly over a sequence of calculations. + * It is sometimes known as "Banker's rounding", and is chiefly used in the USA. + */ + public const HALF_EVEN = 9; +} diff --git a/tests/Math/BrickMathCalculatorTest.php b/tests/Math/BrickMathCalculatorTest.php new file mode 100644 index 0000000..e40f00a --- /dev/null +++ b/tests/Math/BrickMathCalculatorTest.php @@ -0,0 +1,95 @@ +add($int1, $int2, $int3); + + $this->assertSame('18', $result->toString()); + } + + public function testSubtract() + { + $int1 = new IntegerValue(5); + $int2 = new IntegerValue(6); + $int3 = new IntegerValue(7); + + $calculator = new BrickMathCalculator(); + + $result = $calculator->subtract($int1, $int2, $int3); + + $this->assertSame('-8', $result->toString()); + } + + public function testMultiply() + { + $int1 = new IntegerValue(5); + $int2 = new IntegerValue(6); + $int3 = new IntegerValue(7); + + $calculator = new BrickMathCalculator(); + + $result = $calculator->multiply($int1, $int2, $int3); + + $this->assertSame('210', $result->toString()); + } + + public function testDivide() + { + $int1 = new IntegerValue(1023); + $int2 = new IntegerValue(6); + $int3 = new IntegerValue(7); + + $calculator = new BrickMathCalculator(); + + $result = $calculator->divide(RoundingMode::HALF_UP, $int1, $int2, $int3); + + $this->assertSame('24', $result->toString()); + } + + public function testFromBase() + { + $calculator = new BrickMathCalculator(); + + $result = $calculator->fromBase('ffffffffffffffffffff', 16); + + $this->assertInstanceOf(IntegerValue::class, $result); + $this->assertSame('1208925819614629174706175', $result->toString()); + } + + public function testToBase() + { + $intValue = new IntegerValue('1208925819614629174706175'); + $calculator = new BrickMathCalculator(); + + $this->assertSame('ffffffffffffffffffff', $calculator->toBase($intValue, 16)); + } + + public function testToHexadecimal() + { + $intValue = new IntegerValue('1208925819614629174706175'); + $calculator = new BrickMathCalculator(); + + $result = $calculator->toHexadecimal($intValue); + + $this->assertInstanceOf(Hexadecimal::class, $result); + $this->assertSame('ffffffffffffffffffff', $result->toString()); + } +}