From 78a6060dc2e36c694cc207c45e82e97d3ab10ca8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 10:19:01 +0000 Subject: [PATCH 1/3] Add a non-numeric value handler for arithmetic operators Arithmetic and ordering operators hand their operands straight to PHP, so a value that is not a number leaks a raw \TypeError out of the library ("Unsupported operand types: string / string") instead of one of its own NXP\Exception\* types, and there is no supported way to say what such a value should mean short of re-registering every arithmetic operator. setNonNumericHandler() takes a callable($value, $operator) whose return value is used in place of the original operand. It is applied to the operators that require a number (+, -, *, /, %, ^, uNeg, uPos, >, >=, < and <=), including the ones redefined by setDivisionByZeroIsZero() and useBCMath(). Operators with defined string or boolean semantics (==, !=, &&, || and !) are left untouched, as are numeric, null and boolean values. Without a handler the behaviour is unchanged, which the existing suite and the new testNonNumericWithoutHandler cases assert. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018EWvvUoaHC1SaNQ23nVrCk --- README.md | 34 +++++++ src/NXP/MathExecutor.php | 121 +++++++++++++++++++---- tests/MathTest.php | 201 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 336 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 753d500..de942a1 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ * Unlimited variable name lengths * String support, as function parameters or as evaluated as a number by PHP * Exceptions on divide by zero, or treat as zero +* Custom handling of non-numeric values reaching an arithmetic operator * Unary Plus and Minus (e.g. +3 or -sin(12)) * Pi ($pi) and Euler's number ($e) support to 11 decimal places * Easily extensible @@ -232,6 +233,39 @@ $executor->addOperator(new Operator("/", false, 180, function($a, $b) { echo $executor->execute('1/0'); ``` +## Non-Numeric Value Support: +Arithmetic and ordering operators expect numbers. When a value that is not a number reaches one of them, it is passed to +PHP as-is, which raises a `\TypeError` for the arithmetic operators (`'N/A' / 2`) and compares as a string for the +ordering ones (`'N/A' > 1` is `true`). Call **setNonNumericHandler()** to decide what such a value means instead: + +```php +$executor->setNonNumericHandler( + function ($value, string $operator) { + // 'N/A' ratings count as zero in every calculation + return 0; + } +); +$executor->setVar('rating', 'N/A'); +echo $executor->execute('rating / 2'); // 0 +``` + +The handler receives the offending value and the name of the operator (`'+'`, `'/'`, `'uNeg'`, ...), so it can react +differently per operator, and whatever it returns is used in place of the original value. Throwing from it turns the +`\TypeError` into an error of your own: + +```php +$executor->setNonNumericHandler( + function ($value, string $operator) { + throw new MathExecutorException("Value ({$value}) is not a number, required by operator ({$operator})"); + } +); +``` + +It is called for the operators that require a number (`+`, `-`, `*`, `/`, `%`, `^`, unary `-` and unary `+`, `>`, `>=`, +`<` and `<=`), including the ones redefined by `setDivisionByZeroIsZero()` and `useBCMath()`. Values that are numeric +(`'3'` included), `null` or boolean never reach it, and the operators with defined string or boolean semantics (`==`, +`!=`, `&&`, `||` and `!`) are never affected. Without a handler nothing changes, which is the default. + ## String Support: Expressions can contain double or single quoted strings that are evaluated the same way as PHP evaluates strings as numbers. You can also pass strings to functions. diff --git a/src/NXP/MathExecutor.php b/src/NXP/MathExecutor.php index 8c06e8a..1725b90 100644 --- a/src/NXP/MathExecutor.php +++ b/src/NXP/MathExecutor.php @@ -44,6 +44,11 @@ class MathExecutor */ protected $onVarValidation = null; + /** + * @var callable|null + */ + protected $onNonNumeric = null; + /** * @var Operator[] */ @@ -228,6 +233,27 @@ public function setVarValidationHandler(?callable $handler) : self return $this; } + /** + * Define a method that will be invoked when a non-numeric value reaches an operator that requires a number. + * The first parameter will be the value, the second the name of the operator ('+', '/', 'uNeg', ...), and the + * returned value will be used in place of the original one. + * + * The handler is only called for values that are neither numeric, null nor boolean, and only for the operators + * that require a number: +, -, *, /, %, ^, uNeg, uPos, >, >=, < and <=. The operators with defined string or + * boolean semantics (==, !=, &&, || and !) are never affected. + * + * Set to null (the default) to keep the standard behavior, where the value is handed to the operator untouched. + * + * @param ?callable $handler callable(mixed $value, string $operator): mixed + * + */ + public function setNonNumericHandler(?callable $handler) : self + { + $this->onNonNumeric = $handler; + + return $this; + } + /** * Remove variable from executor * @@ -286,7 +312,12 @@ public function removeOperator(string $operator) : self */ public function setDivisionByZeroIsZero() : self { - $this->addOperator(new Operator('/', false, 180, static fn($a, $b) => 0 == $b ? 0 : $a / $b)); + $this->addOperator(new Operator('/', false, 180, function($a, $b) { + $a = $this->normalizeOperand($a, '/'); + $b = $this->normalizeOperand($b, '/'); + + return 0 == $b ? 0 : $a / $b; + })); return $this; } @@ -313,20 +344,52 @@ public function clearCache() : self 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) { + $this->addOperator(new Operator('+', false, 170, function($a, $b) { + $a = $this->normalizeOperand($a, '+'); + $b = $this->normalizeOperand($b, '+'); + + return \bcadd("{$a}", "{$b}"); + })); + $this->addOperator(new Operator('-', false, 170, function($a, $b) { + $a = $this->normalizeOperand($a, '-'); + $b = $this->normalizeOperand($b, '-'); + + return \bcsub("{$a}", "{$b}"); + })); + $this->addOperator(new Operator('uNeg', false, 200, function($a) { + $a = $this->normalizeOperand($a, 'uNeg'); + + return \bcsub('0.0', "{$a}"); + })); + $this->addOperator(new Operator('*', false, 180, function($a, $b) { + $a = $this->normalizeOperand($a, '*'); + $b = $this->normalizeOperand($b, '*'); + + return \bcmul("{$a}", "{$b}"); + })); + $this->addOperator(new Operator('/', false, 180, function($a, $b) { /** @todo PHP8: Use throw as expression -> static fn($a, $b) => 0 == $b ? throw new DivisionByZeroException() : $a / $b */ + $a = $this->normalizeOperand($a, '/'); + $b = $this->normalizeOperand($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}"))); + $this->addOperator(new Operator('^', true, 220, function($a, $b) { + $a = $this->normalizeOperand($a, '^'); + $b = $this->normalizeOperand($b, '^'); + + return \bcpow("{$a}", "{$b}"); + })); + $this->addOperator(new Operator('%', false, 180, function($a, $b) { + $a = $this->normalizeOperand($a, '%'); + $b = $this->normalizeOperand($b, '%'); + + return \bcmod("{$a}", "{$b}"); + })); return $this; } @@ -360,16 +423,19 @@ protected function addDefaults() : self protected function defaultOperators() : array { return [ - '+' => [static fn($a, $b) => $a + $b, 170, false], - '-' => [static fn($a, $b) => $a - $b, 170, false], + '+' => [fn($a, $b) => $this->normalizeOperand($a, '+') + $this->normalizeOperand($b, '+'), 170, false], + '-' => [fn($a, $b) => $this->normalizeOperand($a, '-') - $this->normalizeOperand($b, '-'), 170, false], // unary positive token - 'uPos' => [static fn($a) => $a, 200, false], + 'uPos' => [fn($a) => $this->normalizeOperand($a, 'uPos'), 200, false], // unary minus token - 'uNeg' => [static fn($a) => 0 - $a, 200, false], - '*' => [static fn($a, $b) => $a * $b, 180, false], + 'uNeg' => [fn($a) => 0 - $this->normalizeOperand($a, 'uNeg'), 200, false], + '*' => [fn($a, $b) => $this->normalizeOperand($a, '*') * $this->normalizeOperand($b, '*'), 180, false], '/' => [ - static function($a, $b) { + function($a, $b) { /** @todo PHP8: Use throw as expression -> static fn($a, $b) => 0 == $b ? throw new DivisionByZeroException() : $a / $b */ + $a = $this->normalizeOperand($a, '/'); + $b = $this->normalizeOperand($b, '/'); + if (0 == $b) { throw new DivisionByZeroException(); } @@ -379,16 +445,16 @@ static function($a, $b) { 180, false ], - '^' => [static fn($a, $b) => $a ** $b, 220, true], - '%' => [static fn($a, $b) => $a % $b, 180, false], + '^' => [fn($a, $b) => $this->normalizeOperand($a, '^') ** $this->normalizeOperand($b, '^'), 220, true], + '%' => [fn($a, $b) => $this->normalizeOperand($a, '%') % $this->normalizeOperand($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((string)$a, (string)$b) : $a == $b, 140, false], '!=' => [static fn($a, $b) => \is_string($a) || \is_string($b) ? 0 != \strcmp((string)$a, (string)$b) : $a != $b, 140, false], - '>=' => [static fn($a, $b) => $a >= $b, 150, false], - '>' => [static fn($a, $b) => $a > $b, 150, false], - '<=' => [static fn($a, $b) => $a <= $b, 150, false], - '<' => [static fn($a, $b) => $a < $b, 150, false], + '>=' => [fn($a, $b) => $this->normalizeOperand($a, '>=') >= $this->normalizeOperand($b, '>='), 150, false], + '>' => [fn($a, $b) => $this->normalizeOperand($a, '>') > $this->normalizeOperand($b, '>'), 150, false], + '<=' => [fn($a, $b) => $this->normalizeOperand($a, '<=') <= $this->normalizeOperand($b, '<='), 150, false], + '<' => [fn($a, $b) => $this->normalizeOperand($a, '<') < $this->normalizeOperand($b, '<'), 150, false], '!' => [static fn($a) => ! $a, 190, false], ]; } @@ -534,6 +600,21 @@ protected function defaultVars() : array ]; } + /** + * Hands a value that is about to be used by an operator requiring a number to the non-numeric handler, + * when one has been set with setNonNumericHandler and the value is neither numeric, null nor boolean. + * + * @return mixed the value returned by the handler, or the original value when no handler applies + */ + protected function normalizeOperand(mixed $value, string $operator) : mixed + { + if (null === $this->onNonNumeric || null === $value || \is_bool($value) || \is_numeric($value)) { + return $value; + } + + return \call_user_func($this->onNonNumeric, $value, $operator); + } + /** * Default variable validation, ensures that the value is a scalar or array. * @throws MathExecutorException if the value is not a scalar diff --git a/tests/MathTest.php b/tests/MathTest.php index 46c8988..a75fb63 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -1183,4 +1183,205 @@ public function testUnsupportedOperands() : void $this->expectNotToPerformAssertions(); } } + + /** + * Without a handler the operands reach the operator untouched, exactly as before + */ + #[\PHPUnit\Framework\Attributes\DataProvider('nonNumericExpressions')] + public function testNonNumericWithoutHandler(string $expression) : void + { + $calculator = new MathExecutor(); + $calculator->setVar('rating', 'N/A'); + $this->expectException(\TypeError::class); + $calculator->execute($expression); + } + + /** + * Arithmetic expressions on a non-numeric value + * + * @return array> + */ + public static function nonNumericExpressions() : array + { + return [ + ['rating + 1'], + ['1 + rating'], + ['rating - 1'], + ['rating * 2'], + ['rating / 2'], + ['rating % 2'], + ['rating ^ 2'], + ['-rating'], + ]; + } + + public function testNonNumericComparisonWithoutHandler() : void + { + $calculator = new MathExecutor(); + $calculator->setVar('rating', 'N/A'); + + // PHP compares a non-numeric string with a number as a string, and so does the library + $this->assertEquals(true, $calculator->execute('rating > 1')); + $this->assertEquals(false, $calculator->execute('rating < 1')); + $this->assertEquals('N/A', $calculator->execute('+rating')); + } + + public function testNonNumericHandler() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'N/A'); + $calculator->setVar('blank', ''); + + $this->assertEquals(0, $calculator->execute('rating / 2')); + $this->assertEquals(1, $calculator->execute('1 + rating')); + $this->assertEquals(-1, $calculator->execute('rating - 1')); + $this->assertEquals(0, $calculator->execute('rating * 2')); + $this->assertEquals(0, $calculator->execute('rating % 2')); + $this->assertEquals(0, $calculator->execute('rating ^ 2')); + $this->assertEquals(0, $calculator->execute('-rating')); + $this->assertEquals(0, $calculator->execute('+rating')); + $this->assertEquals(false, $calculator->execute('rating > 1')); + $this->assertEquals(false, $calculator->execute('rating >= 1')); + $this->assertEquals(true, $calculator->execute('rating < 1')); + $this->assertEquals(true, $calculator->execute('rating <= 1')); + $this->assertEquals(1, $calculator->execute('blank + 1')); + } + + public function testNonNumericHandlerReceivesTheOperator() : void + { + $operators = []; + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static function($value, $operator) use (&$operators) { + $operators[] = $operator; + + return 0; + }); + $calculator->setVar('rating', 'N/A'); + + foreach (['rating + 1', 'rating - 1', 'rating * 1', 'rating / 1', 'rating % 1', 'rating ^ 1', '-rating', '+rating', 'rating > 1', 'rating >= 1', 'rating < 1', 'rating <= 1'] as $expression) { + $calculator->execute($expression); + } + + $this->assertEquals(['+', '-', '*', '/', '%', '^', 'uNeg', 'uPos', '>', '>=', '<', '<='], $operators); + } + + public function testNonNumericHandlerCanReturnAnyValue() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 'N/A' === $value ? 10 : 0); + $calculator->setVar('rating', 'N/A'); + $calculator->setVar('other', 'TBD'); + + $this->assertEquals(11, $calculator->execute('rating + 1')); + $this->assertEquals(1, $calculator->execute('other + 1')); + } + + public function testNonNumericHandlerException() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static function($value, $operator) : void { + throw new MathExecutorException("Value ({$value}) is not a number, required by operator ({$operator})"); + }); + $calculator->setVar('rating', 'N/A'); + + $this->expectException(MathExecutorException::class); + $this->expectExceptionMessage('Value (N/A) is not a number, required by operator (/)'); + $calculator->execute('rating / 2'); + } + + public function testNonNumericHandlerIgnoresNumbers() : void + { + $calls = 0; + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static function($value, $operator) use (&$calls) { + ++$calls; + + return 0; + }); + $calculator->setVar('nothing', null); + $calculator->setVar('yes', true); + + $this->assertEquals(6, $calculator->execute("'3' * 2")); + $this->assertEquals(5.5, $calculator->execute("3 + '2.5'")); + $this->assertEquals(1, $calculator->execute('nothing + 1')); + $this->assertEquals(2, $calculator->execute('yes + 1')); + $this->assertEquals(0, $calls); + } + + public function testNonNumericHandlerDoesNotAffectStringOperators() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'N/A'); + + $this->assertEquals(true, $calculator->execute("rating == 'N/A'")); + $this->assertEquals(false, $calculator->execute("rating != 'N/A'")); + $this->assertEquals(true, $calculator->execute("rating != 'TBD'")); + $this->assertEquals(true, $calculator->execute('rating && 1')); + $this->assertEquals(true, $calculator->execute('rating || 0')); + $this->assertEquals(false, $calculator->execute('!rating')); + } + + public function testNonNumericHandlerCanBeRemoved() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'N/A'); + $this->assertEquals(0, $calculator->execute('rating / 2')); + + $calculator->setNonNumericHandler(null); + $this->expectException(\TypeError::class); + $calculator->execute('rating / 2'); + } + + public function testNonNumericHandlerSurvivesClone() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + + $clone = clone $calculator; + $clone->setVar('rating', 'N/A'); + + $this->assertEquals(0, $clone->execute('rating / 2')); + } + + public function testNonNumericHandlerWithDivisionByZeroIsZero() : void + { + $calculator = new MathExecutor(); + $calculator->setDivisionByZeroIsZero(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'N/A'); + + $this->assertEquals(0, $calculator->execute('rating / 2')); + $this->assertEquals(0, $calculator->execute('2 / rating')); + $this->assertEquals(0, $calculator->execute('10 / 0')); + } + + public function testNonNumericHandlerWithBCMath() : void + { + $calculator = new MathExecutor(); + $calculator->useBCMath(2); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'N/A'); + + $this->assertEquals('1.00', $calculator->execute('rating + 1')); + $this->assertEquals('-1.00', $calculator->execute('rating - 1')); + $this->assertEquals('0.00', $calculator->execute('rating * 2')); + $this->assertEquals('0.00', $calculator->execute('-rating')); + $this->assertEquals('0.00', $calculator->execute('rating ^ 2')); + $this->assertEquals('0.00', $calculator->execute('rating % 2')); + $this->assertEquals('0.00', $calculator->execute('rating / 2')); + } + + public function testNonNumericHandlerWithBCMathDivisionByNonNumeric() : void + { + $calculator = new MathExecutor(); + $calculator->useBCMath(2); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'N/A'); + + $this->expectException(DivisionByZeroException::class); + $calculator->execute('2 / rating'); + } } From eebb83d789da1b35c65d06cb68bec60b5785c93a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 10:47:58 +0000 Subject: [PATCH 2/3] Keep arrays and string ordering out of the non-numeric handler Two cases where installing a handler changed results that were already meaningful without one, breaking the promise that the handler only affects values that have no sensible numeric reading: Arrays are a supported variable type, accepted by defaultVarValidation and used by avg(), min() and max(), and PHP gives + a defined meaning for them. "[1, 2] + [3, 4]" returned the array union but 0 once any handler was set, and the handler was invoked with an array in a parameter documented as a scalar. Arrays now short circuit in normalizeOperand along with numeric, null and boolean values. The ordering operators normalized both operands unconditionally, so "'apple' < 'banana'" flipped from true to false once a handler was set, while == and != deliberately keep their strcmp semantics. Comparing two non-numeric values is now left alone, and the handler only applies when the other side is a number, which is the case this feature is about: PHP would otherwise compare that number as a string, so "rating > 1" still resolves through the handler. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018EWvvUoaHC1SaNQ23nVrCk --- README.md | 13 ++++++-- src/NXP/MathExecutor.php | 71 ++++++++++++++++++++++++++++++++++------ tests/MathTest.php | 38 +++++++++++++++++++++ 3 files changed, 109 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index de942a1..7f05adb 100644 --- a/README.md +++ b/README.md @@ -262,9 +262,16 @@ $executor->setNonNumericHandler( ``` It is called for the operators that require a number (`+`, `-`, `*`, `/`, `%`, `^`, unary `-` and unary `+`, `>`, `>=`, -`<` and `<=`), including the ones redefined by `setDivisionByZeroIsZero()` and `useBCMath()`. Values that are numeric -(`'3'` included), `null` or boolean never reach it, and the operators with defined string or boolean semantics (`==`, -`!=`, `&&`, `||` and `!`) are never affected. Without a handler nothing changes, which is the default. +`<` and `<=`), including the ones redefined by `setDivisionByZeroIsZero()` and `useBCMath()`. Without a handler nothing +changes, which is the default. + +These are never affected: +* Values that are numeric (`'3'` included), `null`, boolean or array. Arrays are a supported variable type, so + `[1, 2] + [3, 4]` keeps its PHP meaning. +* The operators with defined string or boolean semantics: `==`, `!=`, `&&`, `||` and `!`. +* An ordering operator comparing two non-numeric values, which stays a string comparison, consistent with `==` and + `!=`. So `'apple' < 'banana'` is still `true`, while `rating > 1` uses the handler, because the other side is a + number and PHP would otherwise compare that number as a string. ## String Support: Expressions can contain double or single quoted strings that are evaluated the same way as PHP evaluates strings as numbers. You can also pass strings to functions. diff --git a/src/NXP/MathExecutor.php b/src/NXP/MathExecutor.php index 1725b90..e3b762c 100644 --- a/src/NXP/MathExecutor.php +++ b/src/NXP/MathExecutor.php @@ -238,9 +238,10 @@ public function setVarValidationHandler(?callable $handler) : self * The first parameter will be the value, the second the name of the operator ('+', '/', 'uNeg', ...), and the * returned value will be used in place of the original one. * - * The handler is only called for values that are neither numeric, null nor boolean, and only for the operators - * that require a number: +, -, *, /, %, ^, uNeg, uPos, >, >=, < and <=. The operators with defined string or - * boolean semantics (==, !=, &&, || and !) are never affected. + * The handler is only called for values that are neither numeric, null, boolean nor array, and only for the + * operators that require a number: +, -, *, /, %, ^, uNeg, uPos, >, >=, < and <=. The operators with defined + * string or boolean semantics (==, !=, &&, || and !) are never affected, and neither is the comparison of two + * non-numeric values by an ordering operator, which stays a string comparison. * * Set to null (the default) to keep the standard behavior, where the value is handed to the operator untouched. * @@ -451,10 +452,42 @@ function($a, $b) { '||' => [static fn($a, $b) => $a || $b, 90, false], '==' => [static fn($a, $b) => \is_string($a) || \is_string($b) ? 0 == \strcmp((string)$a, (string)$b) : $a == $b, 140, false], '!=' => [static fn($a, $b) => \is_string($a) || \is_string($b) ? 0 != \strcmp((string)$a, (string)$b) : $a != $b, 140, false], - '>=' => [fn($a, $b) => $this->normalizeOperand($a, '>=') >= $this->normalizeOperand($b, '>='), 150, false], - '>' => [fn($a, $b) => $this->normalizeOperand($a, '>') > $this->normalizeOperand($b, '>'), 150, false], - '<=' => [fn($a, $b) => $this->normalizeOperand($a, '<=') <= $this->normalizeOperand($b, '<='), 150, false], - '<' => [fn($a, $b) => $this->normalizeOperand($a, '<') < $this->normalizeOperand($b, '<'), 150, false], + '>=' => [ + function($a, $b) { + [$a, $b] = $this->normalizeComparisonOperands($a, $b, '>='); + + return $a >= $b; + }, + 150, + false + ], + '>' => [ + function($a, $b) { + [$a, $b] = $this->normalizeComparisonOperands($a, $b, '>'); + + return $a > $b; + }, + 150, + false + ], + '<=' => [ + function($a, $b) { + [$a, $b] = $this->normalizeComparisonOperands($a, $b, '<='); + + return $a <= $b; + }, + 150, + false + ], + '<' => [ + function($a, $b) { + [$a, $b] = $this->normalizeComparisonOperands($a, $b, '<'); + + return $a < $b; + }, + 150, + false + ], '!' => [static fn($a) => ! $a, 190, false], ]; } @@ -601,20 +634,38 @@ protected function defaultVars() : array } /** - * Hands a value that is about to be used by an operator requiring a number to the non-numeric handler, - * when one has been set with setNonNumericHandler and the value is neither numeric, null nor boolean. + * Hands a value that is about to be used by an operator requiring a number to the non-numeric handler, when one + * has been set with setNonNumericHandler and the value is neither numeric, null, boolean nor array. * * @return mixed the value returned by the handler, or the original value when no handler applies */ protected function normalizeOperand(mixed $value, string $operator) : mixed { - if (null === $this->onNonNumeric || null === $value || \is_bool($value) || \is_numeric($value)) { + if (null === $this->onNonNumeric || null === $value || \is_bool($value) || \is_numeric($value) || \is_array($value)) { return $value; } return \call_user_func($this->onNonNumeric, $value, $operator); } + /** + * Applies the non-numeric handler to the operands of an ordering operator. + * + * Comparing two values that are both non-numeric is a string comparison, which is meaningful and consistent + * with == and !=, so it is left untouched. The handler is only applied when the other side is a number, the + * case where PHP would otherwise compare that number as a string. + * + * @return array{mixed, mixed} the operands to compare + */ + protected function normalizeComparisonOperands(mixed $a, mixed $b, string $operator) : array + { + if (null === $this->onNonNumeric || (! \is_numeric($a) && ! \is_numeric($b))) { + return [$a, $b]; + } + + return [$this->normalizeOperand($a, $operator), $this->normalizeOperand($b, $operator)]; + } + /** * Default variable validation, ensures that the value is a scalar or array. * @throws MathExecutorException if the value is not a scalar diff --git a/tests/MathTest.php b/tests/MathTest.php index a75fb63..5d26852 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -1245,6 +1245,8 @@ public function testNonNumericHandler() : void $this->assertEquals(false, $calculator->execute('rating >= 1')); $this->assertEquals(true, $calculator->execute('rating < 1')); $this->assertEquals(true, $calculator->execute('rating <= 1')); + $this->assertEquals(true, $calculator->execute('1 > rating')); + $this->assertEquals(false, $calculator->execute('1 < rating')); $this->assertEquals(1, $calculator->execute('blank + 1')); } @@ -1323,6 +1325,42 @@ public function testNonNumericHandlerDoesNotAffectStringOperators() : void $this->assertEquals(false, $calculator->execute('!rating')); } + public function testNonNumericHandlerDoesNotAffectStringOrdering() : void + { + $calls = 0; + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static function($value, $operator) use (&$calls) { + ++$calls; + + return 0; + }); + + // Comparing two non-numeric values stays a string comparison, just like == and != + $this->assertEquals(true, $calculator->execute("'apple' < 'banana'")); + $this->assertEquals(false, $calculator->execute("'apple' > 'banana'")); + $this->assertEquals(true, $calculator->execute("'apple' <= 'banana'")); + $this->assertEquals(false, $calculator->execute("'apple' >= 'banana'")); + $this->assertEquals(0, $calls); + } + + public function testNonNumericHandlerDoesNotAffectArrays() : void + { + $calls = 0; + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static function($value, $operator) use (&$calls) { + ++$calls; + + return 0; + }); + $calculator->setVar('first', [1, 2]); + $calculator->setVar('second', [3, 4, 5]); + + // Arrays are a supported variable type, so they keep reaching the operator untouched + $this->assertEquals([1, 2, 5], $calculator->execute('first + second')); + $this->assertEquals(1.5, $calculator->execute('avg(first)')); + $this->assertEquals(0, $calls); + } + public function testNonNumericHandlerCanBeRemoved() : void { $calculator = new MathExecutor(); From 27c1f4220aaab103722448431b780bd43270bb1c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 10:49:06 +0000 Subject: [PATCH 3/3] Throw DivisionByZeroException on modulo by zero Modulo by zero leaked PHP's raw \DivisionByZeroError out of the library instead of NXP\Exception\DivisionByZeroException, and setDivisionByZeroIsZero() did not cover %, so "10 % 0" threw a PHP Error that callers catching MathExecutorException could not catch and that setDivisionByZeroIsZero() could not turn off. useBCMath()'s bcmod() had the same behaviour. % now mirrors / in all three places: it throws the library's exception, setDivisionByZeroIsZero() registers it alongside /, and the BCMath variant checks the divisor before calling bcmod(). This is the one behaviour change in this branch for callers that set no non-numeric handler: "10 % 0" throws DivisionByZeroException where it previously threw \DivisionByZeroError. Operands that are non-numeric without a handler are unaffected, because 0 == 'N/A' is false on PHP 8, so they still reach the operator and raise the same \TypeError as before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018EWvvUoaHC1SaNQ23nVrCk --- README.md | 6 +++--- src/NXP/MathExecutor.php | 27 +++++++++++++++++++++++++-- tests/MathTest.php | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7f05adb..ea2e46d 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ * Dynamic variable resolution (delayed computation) * Unlimited variable name lengths * String support, as function parameters or as evaluated as a number by PHP -* Exceptions on divide by zero, or treat as zero +* Exceptions on divide or modulo by zero, or treat as zero * Custom handling of non-numeric values reaching an arithmetic operator * Unary Plus and Minus (e.g. +3 or -sin(12)) * Pi ($pi) and Euler's number ($e) support to 11 decimal places @@ -210,7 +210,7 @@ By default, `MathExecutor` uses PHP floating point math, but if you need a fixed `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 front 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 +Division and modulo by zero throw a `\NXP\Exception\DivisionByZeroException` by default ```php try { echo $executor->execute('1/0'); @@ -218,7 +218,7 @@ try { echo $e->getMessage(); } ``` -Or call setDivisionByZeroIsZero +Or call setDivisionByZeroIsZero, which covers both `/` and `%` ```php echo $executor->setDivisionByZeroIsZero()->execute('1/0'); ``` diff --git a/src/NXP/MathExecutor.php b/src/NXP/MathExecutor.php index e3b762c..20c605b 100644 --- a/src/NXP/MathExecutor.php +++ b/src/NXP/MathExecutor.php @@ -309,7 +309,7 @@ public function removeOperator(string $operator) : self } /** - * Set division by zero returns zero instead of throwing DivisionByZeroException + * Set division and modulo by zero to return zero instead of throwing DivisionByZeroException */ public function setDivisionByZeroIsZero() : self { @@ -319,6 +319,12 @@ public function setDivisionByZeroIsZero() : self return 0 == $b ? 0 : $a / $b; })); + $this->addOperator(new Operator('%', false, 180, function($a, $b) { + $a = $this->normalizeOperand($a, '%'); + $b = $this->normalizeOperand($b, '%'); + + return 0 == $b ? 0 : $a % $b; + })); return $this; } @@ -389,6 +395,10 @@ public function useBCMath(int $scale = 2) : self $a = $this->normalizeOperand($a, '%'); $b = $this->normalizeOperand($b, '%'); + if (0 == $b) { + throw new DivisionByZeroException(); + } + return \bcmod("{$a}", "{$b}"); })); @@ -447,7 +457,20 @@ function($a, $b) { false ], '^' => [fn($a, $b) => $this->normalizeOperand($a, '^') ** $this->normalizeOperand($b, '^'), 220, true], - '%' => [fn($a, $b) => $this->normalizeOperand($a, '%') % $this->normalizeOperand($b, '%'), 180, false], + '%' => [ + function($a, $b) { + $a = $this->normalizeOperand($a, '%'); + $b = $this->normalizeOperand($b, '%'); + + if (0 == $b) { + throw new DivisionByZeroException(); + } + + return $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((string)$a, (string)$b) : $a == $b, 140, false], diff --git a/tests/MathTest.php b/tests/MathTest.php index 5d26852..deec017 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -559,6 +559,22 @@ public function testZeroDivision() : void $calculator = new MathExecutor(); $calculator->setDivisionByZeroIsZero(); $this->assertEquals(0, $calculator->execute('10 / 0')); + $this->assertEquals(0, $calculator->execute('10 % 0')); + } + + public function testZeroModuloException() : void + { + $calculator = new MathExecutor(); + $this->expectException(DivisionByZeroException::class); + $calculator->execute('10 % 0'); + } + + public function testZeroModuloExceptionWithBCMath() : void + { + $calculator = new MathExecutor(); + $calculator->useBCMath(2); + $this->expectException(DivisionByZeroException::class); + $calculator->execute('10 % 0'); } public function testUnaryOperators() : void @@ -1412,6 +1428,28 @@ public function testNonNumericHandlerWithBCMath() : void $this->assertEquals('0.00', $calculator->execute('rating / 2')); } + public function testNonNumericHandlerModuloByNonNumeric() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'N/A'); + + // A handler that turns the divisor into zero reaches the library's own exception, as division does + $this->expectException(DivisionByZeroException::class); + $calculator->execute('2 % rating'); + } + + public function testNonNumericHandlerModuloByNonNumericIsZero() : void + { + $calculator = new MathExecutor(); + $calculator->setDivisionByZeroIsZero(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'N/A'); + + $this->assertEquals(0, $calculator->execute('2 % rating')); + $this->assertEquals(0, $calculator->execute('2 / rating')); + } + public function testNonNumericHandlerWithBCMathDivisionByNonNumeric() : void { $calculator = new MathExecutor();