From a944fe4e5631a6b3085ee2962e3159261f90bfcd Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Wed, 1 Jun 2022 18:11:51 -0400 Subject: [PATCH] Bcmath (#115) * Add useBCMath * Support for % operator (mod) --- README.md | 10 +- src/NXP/MathExecutor.php | 44 ++++++- tests/MathTest.php | 242 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 289 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 34b59c4..836612e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # A simple and extensible math expressions calculator ## Features: -* Built in support for +, -, *, / and power (^) operators +* Built in support for +, -, *, %, / and power (^) operators * Paratheses () and arrays [] are fully supported * Logical operators (==, !=, <, <, >=, <=, &&, ||) * Built in support for most PHP math functions @@ -101,7 +101,7 @@ $executor->calculate('avarage(1, 3, 4, 8)'); // 4 ``` ## Operators: -Default operators: `+ - * / ^` +Default operators: `+ - * / % ^` Add custom operator to executor: @@ -111,7 +111,7 @@ use NXP\Classes\Operator; $executor->addOperator(new Operator( '%', // Operator sign false, // Is right associated operator - 170, // Operator priority + 180, // Operator priority function (&$stack) { $op2 = array_pop($stack); @@ -189,6 +189,10 @@ $calculator->setVarNotFoundHandler( ); ``` +## Floating Point BCMath Support +By default, `MathExecutor` uses PHP floating point math, but if you need a fixed precision, call **useBCMath()**. Precision defaults to 2 decimal points, or pass the required number. +`WARNING`: Functions may return a PHP floating point number. By doing the basic math functions on the results, you will get back a fixed number of decimal points. Use a plus sign in from of any stand alone function to return the proper number of decimal places. + ## Division By Zero Support: Division by zero throws a `\NXP\Exception\DivisionByZeroException` by default ```php diff --git a/src/NXP/MathExecutor.php b/src/NXP/MathExecutor.php index 1e1cd2d..6709e21 100644 --- a/src/NXP/MathExecutor.php +++ b/src/NXP/MathExecutor.php @@ -263,7 +263,7 @@ public function removeVars() : self * * @return array of operator class names */ - public function getOperators() + public function getOperators() : array { return $this->operators; } @@ -279,6 +279,18 @@ public function getFunctions() : array return $this->functions; } + /** + * Remove a specific operator + * + * @return array of operator class names + */ + public function removeOperator(string $operator) : self + { + unset($this->operators[$operator]); + + return $this; + } + /** * Set division by zero returns zero instead of throwing DivisionByZeroException */ @@ -301,16 +313,39 @@ public function getCache() : array /** * Clear token's cache */ - public function clearCache() : void + public function clearCache() : self { $this->cache = []; + + return $this; + } + + public function useBCMath(int $scale = 2) : self + { + \bcscale($scale); + $this->addOperator(new Operator('+', false, 170, static fn($a, $b) => \bcadd("{$a}", "{$b}"))); + $this->addOperator(new Operator('-', false, 170, static fn($a, $b) => \bcsub("{$a}", "{$b}"))); + $this->addOperator(new Operator('uNeg', false, 200, static fn($a) => \bcsub('0.0', "{$a}"))); + $this->addOperator(new Operator('*', false, 180, static fn($a, $b) => \bcmul("{$a}", "{$b}"))); + $this->addOperator(new Operator('/', false, 180, static function($a, $b) { + /** @todo PHP8: Use throw as expression -> static fn($a, $b) => 0 == $b ? throw new DivisionByZeroException() : $a / $b */ + if (0 == $b) { + throw new DivisionByZeroException(); + } + + return \bcdiv("{$a}", "{$b}"); + })); + $this->addOperator(new Operator('^', true, 220, static fn($a, $b) => \bcpow("{$a}", "{$b}"))); + $this->addOperator(new Operator('%', false, 180, static fn($a, $b) => \bcmod("{$a}", "{$b}"))); + + return $this; } /** * Set default operands and functions * @throws ReflectionException */ - protected function addDefaults() : void + protected function addDefaults() : self { foreach ($this->defaultOperators() as $name => $operator) { [$callable, $priority, $isRightAssoc] = $operator; @@ -323,6 +358,8 @@ protected function addDefaults() : void $this->onVarValidation = [$this, 'defaultVarValidation']; $this->variables = $this->defaultVars(); + + return $this; } /** @@ -352,6 +389,7 @@ static function($a, $b) { /** @todo PHP8: Use throw as expression -> static fn($ false ], '^' => [static fn($a, $b) => \pow($a, $b), 220, true], + '%' => [static fn($a, $b) => $a % $b, 180, false], '&&' => [static fn($a, $b) => $a && $b, 100, false], '||' => [static fn($a, $b) => $a || $b, 90, false], '==' => [static fn($a, $b) => \is_string($a) || \is_string($b) ? 0 == \strcmp($a, $b) : $a == $b, 140, false], diff --git a/tests/MathTest.php b/tests/MathTest.php index 7af7c59..94a14d4 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -113,6 +113,7 @@ public function providerExpressions() ['tanh(1.5)'], ['0.1 + 0.2'], + ['0.1 + 0.2 - 0.3'], ['1 + 2'], ['0.1 - 0.2'], @@ -246,7 +247,246 @@ public function providerExpressions() ['max(1,2,4.9,3)'], ['min(1,2,4.9,3)'], ['max([1,2,4.9,3])'], - ['min([1,2,4.9,3])'] + ['min([1,2,4.9,3])'], + + ['4 % 4'], + ['7 % 4'], + ['99 % 4'], + ['123 % 7'], + ]; + } + + /** + * @dataProvider bcMathExpressions + */ + public function testBCMathCalculating(string $expression, string $expected = '') : void + { + $calculator = new MathExecutor(); + $calculator->useBCMath(); + + if ('' === $expected) + { + $expected = $expression; + } + + /** @var float $phpResult */ + eval('$phpResult = ' . $expected . ';'); + + try { + $result = $calculator->execute($expression); + } catch (Exception $e) { + $this->fail(\sprintf('Exception: %s (%s:%d), expression was: %s', \get_class($e), $e->getFile(), $e->getLine(), $expression)); + } + $this->assertEquals($phpResult, $result, "Expression was: {$expression}"); + } + + /** + * Expressions data provider + * + * Most tests can go in here. The idea is that each expression will be evaluated by MathExecutor and by PHP with eval. + * The results should be the same. If they are not, then the test fails. No need to add extra test unless you are doing + * something more complex and not a simple mathmatical expression. + */ + public function bcMathExpressions() + { + return [ + ['-5'], + ['-5+10'], + ['4-5'], + ['4 -5'], + ['(4*2)-5'], + ['(4*2) - 5'], + ['4*-5'], + ['4 * -5'], + ['+5'], + ['+(3+2)'], + ['+(+3+2)'], + ['+(-3+2)'], + ['-5'], + ['-(-5)'], + ['-(+5)'], + ['+(-5)'], + ['+(+5)'], + ['-(3+2)'], + ['-(-3+-2)'], + + ['abs(1.5)'], + ['acos(0.15)'], + ['acosh(1.5)'], + ['asin(0.15)'], + ['atan(0.15)'], + ['atan2(1.5, 3.5)'], + ['atanh(0.15)'], + ['bindec("10101")'], + ['ceil(1.5)'], + ['cos(1.5)'], + ['cosh(1.5)'], + ['decbin("15")'], + ['dechex("15")'], + ['decoct("15")'], + ['deg2rad(1.5)'], + ['exp(1.5)'], + ['expm1(1.5)'], + ['floor(1.5)'], + ['fmod(1.5, 3.5)'], + ['hexdec("abcdef")'], + ['hypot(1.5, 3.5)'], + ['intdiv(10, 2)'], + ['log(1.5)'], + ['log10(1.5)'], + ['log1p(1.5)'], + ['max(1.5, 3.5)'], + ['min(1.5, 3.5)'], + ['octdec("15")'], + ['pi()'], + ['pow(1.5, 3.5)'], + ['rad2deg(1.5)'], + ['round(1.5)'], + ['sin(1.5)'], + ['sin(12)'], + ['+sin(12)'], + ['-sin(12)', '0.53'], + ['sinh(1.5)'], + ['sqrt(1.5)'], + ['tan(1.5)'], + ['tanh(1.5)'], + + ['0.1 + 0.2'], + ['0.1 + 0.2 - 0.3'], + ['1 + 2'], + + ['0.1 - 0.2'], + ['1 - 2'], + + ['0.1 * 2'], + ['1 * 2'], + + ['0.1 / 0.2'], + ['1 / 2'], + + ['2 * 2 + 3 * 3'], + ['2 * 2 / 3 * 3', '3.99'], + ['2 / 2 / 3 / 3', '0.11'], + ['2 / 2 * 3 / 3'], + ['2 / 2 * 3 * 3'], + + ['1 + 0.6 - 3 * 2 / 50'], + + ['(5 + 3) * -1'], + + ['-2- 2*2'], + ['2- 2*2'], + ['2-(2*2)'], + ['(2- 2)*2'], + ['2 + 2*2'], + ['2+ 2*2'], + ['2+2*2'], + ['(2+2)*2'], + ['(2 + 2)*-2'], + ['(2+-2)*2'], + + ['1 + 2 * 3 / (min(1, 5) + 2 + 1)'], + ['1 + 2 * 3 / (min(1, 5) - 2 + 5)'], + ['1 + 2 * 3 / (min(1, 5) * 2 + 1)'], + ['1 + 2 * 3 / (min(1, 5) / 2 + 1)'], + ['1 + 2 * 3 / (min(1, 5) / 2 * 1)'], + ['1 + 2 * 3 / (min(1, 5) / 2 / 1)'], + ['1 + 2 * 3 / (3 + min(1, 5) + 2 + 1)', '1.85'], + ['1 + 2 * 3 / (3 - min(1, 5) - 2 + 1)'], + ['1 + 2 * 3 / (3 * min(1, 5) * 2 + 1)', '1.85'], + ['1 + 2 * 3 / (3 / min(1, 5) / 2 + 1)'], + + ['(1 + 2) * 3 / (3 / min(1, 5) / 2 + 1)'], + + ['sin(10) * cos(50) / min(10, 20/2)', '-0.05'], + ['sin(10) * cos(50) / min(10, (20/2))', '-0.05'], + ['sin(10) * cos(50) / min(10, (max(10,20)/2))', '-0.05'], + + ['1 + "2" / 3', '1.66'], + ["1.5 + '2.5' / 4", '2.12'], + ['1.5 + "2.5" * ".5"'], + + ['-1 + -2'], + ['-1+-2'], + ['-1- -2'], + ['-1/-2'], + ['-1*-2'], + + ['(1+2+3+4-5)*7/100'], + ['(-1+2+3+4- 5)*7/100'], + ['(1+2+3+4- 5)*7/100'], + ['( 1 + 2 + 3 + 4 - 5 ) * 7 / 100'], + + ['1 && 0'], + ['1 && 0 && 1'], + ['1 || 0'], + ['1 && 0 || 1'], + + ['5 == 3'], + ['5 == 5'], + ['5 != 3'], + ['5 != 5'], + ['5 > 3'], + ['3 > 5'], + ['3 >= 5'], + ['3 >= 3'], + ['3 < 5'], + ['5 < 3'], + ['3 <= 5'], + ['5 <= 5'], + ['10 < 9 || 4 > (2+1)'], + ['10 < 9 || 4 > (-2+1)'], + ['10 < 9 || 4 > (2+1) && 5 == 5 || 4 != 6 || 3 >= 4 || 3 <= 7'], + + ['1 + 5 == 3 + 1'], + ['1 + 5 == 5 + 1'], + ['1 + 5 != 3 + 1'], + ['1 + 5 != 5 + 1'], + ['1 + 5 > 3 + 1'], + ['1 + 3 > 5 + 1'], + ['1 + 3 >= 5 + 1'], + ['1 + 3 >= 3 + 1'], + ['1 + 3 < 5 + 1'], + ['1 + 5 < 3 + 1'], + ['1 + 3 <= 5 + 1'], + ['1 + 5 <= 5 + 1'], + + ['(-4)'], + ['(-4 + 5)'], + ['(3 * 1)'], + ['(-3 * -1)'], + ['1 + (-3 * -1)'], + ['1 + ( -3 * 1)'], + ['1 + (3 *-1)'], + ['1 - 0'], + ['1-0'], + + ['-(1.5)'], + ['-log(4)', '-1.38'], + ['0-acosh(1.5)', '-0.96'], + ['-acosh(1.5)', '-0.96'], + ['-(-4)'], + ['-(-4 + 5)'], + ['-(3 * 1)'], + ['-(-3 * -1)'], + ['-1 + (-3 * -1)'], + ['-1 + ( -3 * 1)'], + ['-1 + (3 *-1)'], + ['-1 - 0'], + ['-1-0'], + ['-(4*2)-5'], + ['-(4*-2)-5'], + ['-(-4*2) - 5'], + ['-4*-5'], + ['max(1,2,4.9,3)'], + ['min(1,2,4.9,3)'], + ['max([1,2,4.9,3])'], + ['min([1,2,4.9,3])'], + + ['4 % 4'], + ['7 % 4'], + ['99 % 4'], + ['123 % 7'], ]; }