From 1b8d71d953d2ad9c9eb97d1fed9fe4ed4f8e83d3 Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Tue, 21 Mar 2023 10:10:06 -0500 Subject: [PATCH 01/20] Php unit10 (#124) * Static PHPUnit providers * Limit PHP upgrades till new PHP version is tested * Upgrade PHPUnit config schema --- .gitignore | 1 + composer.json | 2 +- phpunit.xml.dist | 30 ++++++++++++------------------ tests/MathTest.php | 8 ++++---- 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 9aedff4..1054073 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ vendor/ .idea/ composer.lock +.phpunit.cache .phpunit.result.cache .vscode .php-cs-fixer.cache diff --git a/composer.json b/composer.json index a0dee1f..745e3d5 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ }, "require": { - "php": ">=8.0" + "php": ">=8.0 <8.3" }, "require-dev": { "phpunit/phpunit": ">=9.0", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f591521..56bd7e0 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,20 +1,14 @@ - - - - - - ./tests/ - - + + + + ./tests/ + + diff --git a/tests/MathTest.php b/tests/MathTest.php index f8573d3..573b634 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -51,7 +51,7 @@ public function testCalculating(string $expression) : void * * @return array> */ - public function providerExpressions() + public static function providerExpressions() { return [ ['-5'], @@ -295,7 +295,7 @@ public function testBCMathCalculating(string $expression, string $expected = '') * * @return array> */ - public function bcMathExpressions() + public static function bcMathExpressions() { return [ ['-5'], @@ -518,7 +518,7 @@ public function testIncorrectExpressionException(string $expression) : void * * @return array> */ - public function incorrectExpressions() + public static function incorrectExpressions() { return [ ['1 * + '], @@ -1091,7 +1091,7 @@ public function testCalculatingValues(string $expression, mixed $value) : void * * @return array> */ - public function providerExpressionValues() + public static function providerExpressionValues() { return [ ['arccos(0.5)', \acos(0.5)], From e1cca194c57cf242fd8635b2e72e11e990279c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Mar=C3=ADn?= Date: Tue, 21 Mar 2023 16:10:38 +0100 Subject: [PATCH 02/20] fix: strcmp(): Passing null to parameter #1 ($string1) of type string is deprecated (#123) --- src/NXP/MathExecutor.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NXP/MathExecutor.php b/src/NXP/MathExecutor.php index 84dd3d9..2a3fd72 100644 --- a/src/NXP/MathExecutor.php +++ b/src/NXP/MathExecutor.php @@ -382,8 +382,8 @@ static function($a, $b) { '%' => [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], - '!=' => [static fn($a, $b) => \is_string($a) || \is_string($b) ? 0 != \strcmp($a, $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) => \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], From 2a65673caea7be6766a20bf377490b5b6ece0276 Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Mon, 10 Jul 2023 16:36:57 -0400 Subject: [PATCH 03/20] Composer updates (#126) * Correct average typo * Update to PHP CS FIxer V3.21 --- .php-cs-fixer.php | 4 ++-- README.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 534bd1d..7017c03 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -69,7 +69,7 @@ // Replace core functions calls returning constants with the constants. 'function_to_constant' => true, // Ensure single space between function's argument and its typehint. - 'function_typehint_space' => true, + 'type_declaration_spaces' => true, // Renames PHPDoc tags. 'general_phpdoc_tag_rename' => true, // Function `implode` must be called with 2 arguments in the documented order. @@ -229,7 +229,7 @@ // A PHP file without end tag must always end with a single empty line feed. 'single_blank_line_at_eof' => true, // There should be exactly one blank line before a namespace declaration. - 'single_blank_line_before_namespace' => true, + 'blank_lines_before_namespace' => ['max_line_breaks' => 2, 'min_line_breaks' => 2], // There MUST NOT be more than one property or constant declared per statement. 'single_class_element_per_statement' => true, // There MUST be one use keyword per declaration. diff --git a/README.md b/README.md index ea390aa..d84fd17 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,9 @@ $executor->calculate('round(17.119, 2)'); // 17.12 ``` Variable number of parameters: ```php -$executor->addFunction('avarage', function(...$args) {return array_sum($args) / count($args);}); -$executor->calculate('avarage(1,3)'); // 2 -$executor->calculate('avarage(1, 3, 4, 8)'); // 4 +$executor->addFunction('average', function(...$args) {return array_sum($args) / count($args);}); +$executor->calculate('average(1,3)'); // 2 +$executor->calculate('average(1, 3, 4, 8)'); // 4 ``` ## Operators: From ad839b7858d31f4025598193b15ac422f11203d2 Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Wed, 12 Jul 2023 13:27:48 -0400 Subject: [PATCH 04/20] Document all functions in readme (#127) * Correct average typo * Update to PHP CS FIxer V3.21 * Update available functions --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d84fd17..dfb1fb3 100644 --- a/README.md +++ b/README.md @@ -40,21 +40,35 @@ Default functions: * abs * acos (arccos) * acosh +* arccos +* arccosec +* arccot +* arccotan +* arccsc (arccosec) * arcctg (arccot, arccotan) * arcsec -* arccsc (arccosec) +* arcsin +* arctan +* arctg * array * asin (arcsin) * atan (atn, arctan, arctg) * atan2 * atanh +* atn * avg * bindec * ceil * cos +* cosec * cosec (csc) * cosh +* cot +* cotan +* cotg +* csc * ctg (cot, cotan, cotg, ctn) +* ctn * decbin * dechex * decoct @@ -67,6 +81,8 @@ Default functions: * hypot * if * intdiv +* lg +* ln * log (ln) * log10 (lg) * log1p @@ -84,6 +100,8 @@ Default functions: * sqrt * tan (tn, tg) * tanh +* tg +* tn Add custom function to executor: ```php From 1968057f42210e3f5675d46126a1dc6506616ef3 Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Fri, 18 Aug 2023 12:11:23 -0400 Subject: [PATCH 05/20] Phpcs fixer (#129) * PHPCSFixer V3.23 * Run PHPCSFixer --- .php-cs-fixer.php | 4 ++-- src/NXP/Classes/Tokenizer.php | 3 +++ tests/MathTest.php | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 7017c03..b661109 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -148,8 +148,6 @@ 'no_short_bool_cast' => true, // When making a method or function call, there MUST NOT be a space between the method or function name and the opening parenthesis. 'no_spaces_after_function_name' => true, - // There MUST NOT be a space after the opening parenthesis. There MUST NOT be a space before the closing parenthesis. - 'no_spaces_inside_parenthesis' => true, // Removes `@param`, `@return` and `@var` tags that don't provide any useful information. 'no_superfluous_phpdoc_tags' => true, // Remove trailing whitespace at the end of non-blank lines. @@ -242,6 +240,8 @@ 'single_quote' => true, // Each trait `use` must be done as single statement. 'single_trait_insert_per_statement' => true, + // There MUST NOT be a space after the opening parenthesis. There MUST NOT be a space before the closing parenthesis. + 'spaces_inside_parentheses' => false, // Replace all `<>` with `!=`. 'standardize_not_equals' => true, // Lambdas not (indirect) referencing `$this` must be declared `static`. diff --git a/src/NXP/Classes/Tokenizer.php b/src/NXP/Classes/Tokenizer.php index 656961a..e3c3e6a 100644 --- a/src/NXP/Classes/Tokenizer.php +++ b/src/NXP/Classes/Tokenizer.php @@ -133,6 +133,7 @@ public function tokenize() : self $this->allowNegative = false; break; + /** @noinspection PhpMissingBreakStatementInspection */ case 'e' === \strtolower($ch): if (\strlen($this->numberBuffer) && \str_contains($this->numberBuffer, '.')) { @@ -141,6 +142,7 @@ public function tokenize() : self break; } + // no break // Intentionally fall through case $this->isAlpha($ch): @@ -207,6 +209,7 @@ public function tokenize() : self continue 2; } + // could be in exponent, in which case negative should be added to the numberBuffer if ($this->numberBuffer && 'e' == $this->numberBuffer[\strlen($this->numberBuffer) - 1]) { $this->numberBuffer .= $ch; diff --git a/tests/MathTest.php b/tests/MathTest.php index 573b634..8c22224 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -1018,6 +1018,7 @@ public function testSetCustomVarValidator() : void if (\is_scalar($variable) || null === $variable) { return; } + // Allow variables of type DateTime, but not others if (! $variable instanceof \DateTime) { throw new MathExecutorException('Invalid variable type'); From f6750c9c3c944827462f28ceb3db6284458412cb Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Wed, 27 Sep 2023 16:28:28 -0400 Subject: [PATCH 06/20] Improved tests for ! operator (#131) --- tests/MathTest.php | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/MathTest.php b/tests/MathTest.php index 8c22224..bf33793 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -256,8 +256,15 @@ public static function providerExpressions() ['7 % 4'], ['99 % 4'], ['123 % 7'], + ['!(1||0)'], ['!(1&&0)'], + ['!(1)'], + ['!(0)'], + ['! 1'], + ['! 0'], + ['!1'], + ['!0'], ]; } @@ -495,8 +502,15 @@ public static function bcMathExpressions() ['7 % 4'], ['99 % 4'], ['123 % 7'], + ['!(1||0)'], ['!(1&&0)'], + ['!(1)'], + ['!(0)'], + ['! 1'], + ['! 0'], + ['!1'], + ['!0'], ]; } @@ -582,6 +596,21 @@ public function testVariableIncorrectExpressionException() : void $this->assertEquals(0.0, $calculator->execute('$ + $four')); } + public function testNotVariableOperator() : void + { + $calculator = new MathExecutor(); + $calculator->setVar('one', 1); + $calculator->setVar('zero', 0); + $this->assertEquals(false, $calculator->execute('! $one')); + $this->assertEquals(false, $calculator->execute('!$one')); + $this->assertEquals(false, $calculator->execute('! ($one)')); + $this->assertEquals(false, $calculator->execute('!($one)')); + $this->assertEquals(true, $calculator->execute('! $zero')); + $this->assertEquals(true, $calculator->execute('!$zero')); + $this->assertEquals(true, $calculator->execute('! ($zero)')); + $this->assertEquals(true, $calculator->execute('!($zero)')); + } + public function testExponentiation() : void { $calculator = new MathExecutor(); From d9daabf40d64d38c2f4b4468d0e8cdb458e82bcf Mon Sep 17 00:00:00 2001 From: Mathijs <101415133+mathijsqdrop@users.noreply.github.com> Date: Mon, 20 Nov 2023 22:19:47 +0100 Subject: [PATCH 07/20] Update README.md (#132) Fix typo Paretheses -> Parentheses --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dfb1fb3..d446dbc 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## Features: * Built in support for +, -, *, /, % and power (^) operators -* Paratheses () and arrays [] are fully supported +* Parentheses () and arrays [] are fully supported * Logical operators (==, !=, <, <, >=, <=, &&, ||, !) * Built in support for most PHP math functions * Support for BCMath Arbitrary Precision Math From a1f86abc0b4896f287f7851573e5965682b1f2ab Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Thu, 23 Nov 2023 09:38:20 -0600 Subject: [PATCH 08/20] PHP 8.3 (#133) --- .github/workflows/tests.yml | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2db0a46..7596322 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - php: [8.2, 8.1, 8.0] + php: [8.3, 8.2, 8.1, 8.0] dependency-version: [prefer-lowest, prefer-stable] os: [ubuntu-latest, windows-latest] diff --git a/composer.json b/composer.json index 745e3d5..6edd148 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ }, "require": { - "php": ">=8.0 <8.3" + "php": ">=8.0 <8.4" }, "require-dev": { "phpunit/phpunit": ">=9.0", From dca855e25f8ba6ab019c2fe9bd8065d4713d00de Mon Sep 17 00:00:00 2001 From: Laurens Bultynck Date: Thu, 15 Feb 2024 23:51:27 +0100 Subject: [PATCH 09/20] Add support for using none default base value in log function (#134) * Update tests with log using base parameter * Ensure base parameter is allowed for log function --- src/NXP/MathExecutor.php | 4 ++-- tests/MathTest.php | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/NXP/MathExecutor.php b/src/NXP/MathExecutor.php index 2a3fd72..ff0553d 100644 --- a/src/NXP/MathExecutor.php +++ b/src/NXP/MathExecutor.php @@ -468,9 +468,9 @@ protected function defaultFunctions() : array return $this->execute($falseval); }, 'intdiv' => static fn($arg1, $arg2) => \intdiv($arg1, $arg2), - 'ln' => static fn($arg) => \log($arg), + 'ln' => static fn($arg1, $arg2 = M_E) => \log($arg1, $arg2), 'lg' => static fn($arg) => \log10($arg), - 'log' => static fn($arg) => \log($arg), + 'log' => static fn($arg1, $arg2 = M_E) => \log($arg1, $arg2), 'log10' => static fn($arg) => \log10($arg), 'log1p' => static fn($arg) => \log1p($arg), 'max' => static function($arg1, ...$args) { diff --git a/tests/MathTest.php b/tests/MathTest.php index bf33793..b3b78e1 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -97,6 +97,7 @@ public static function providerExpressions() ['hypot(1.5, 3.5)'], ['intdiv(10, 2)'], ['log(1.5)'], + ['log(1.5, 3)'], ['log10(1.5)'], ['log1p(1.5)'], ['max(1.5, 3.5)'], @@ -1148,6 +1149,7 @@ public static function providerExpressionValues() ['decbin(10)', \decbin(10)], ['lg(2)', \log10(2)], ['ln(2)', \log(2)], + ['ln(2, 5)', \log(2, 5)], ['sec(4)', 1 / \cos(4)], ['tg(4)', \tan(4)], ]; From a63e50e6fd5ecbb44cc3a30ae868f39cb4a851e8 Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Fri, 23 Feb 2024 15:14:44 -0500 Subject: [PATCH 10/20] Upgrading actions to v4 for checkout (#135) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7596322..9ed1fd0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 From e35e414779f19313aa5e5188fe8de0fb3c08e4ec Mon Sep 17 00:00:00 2001 From: Melek REBAI Date: Mon, 10 Jun 2024 14:34:02 +0100 Subject: [PATCH 11/20] Update README (#136) Replace the wrong method name `calculate()` with `execute()` --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d446dbc..8ed00a7 100644 --- a/README.md +++ b/README.md @@ -110,14 +110,14 @@ $executor->addFunction('concat', function($arg1, $arg2) {return $arg1 . $arg2;}) Optional parameters: ```php $executor->addFunction('round', function($num, int $precision = 0) {return round($num, $precision);}); -$executor->calculate('round(17.119)'); // 17 -$executor->calculate('round(17.119, 2)'); // 17.12 +$executor->execute('round(17.119)'); // 17 +$executor->execute('round(17.119, 2)'); // 17.12 ``` Variable number of parameters: ```php $executor->addFunction('average', function(...$args) {return array_sum($args) / count($args);}); -$executor->calculate('average(1,3)'); // 2 -$executor->calculate('average(1, 3, 4, 8)'); // 4 +$executor->execute('average(1,3)'); // 2 +$executor->execute('average(1, 3, 4, 8)'); // 4 ``` ## Operators: From b3b9351b28672f87b47522fb6b6eab05ff258fae Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Mon, 28 Oct 2024 15:41:33 -0400 Subject: [PATCH 12/20] PHP 8.4 support (#138) --- .github/workflows/tests.yml | 2 +- .php-cs-fixer.php | 12 ++++++------ composer.json | 2 +- phpunit.xml.dist | 5 +++++ 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9ed1fd0..95f33a1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - php: [8.3, 8.2, 8.1, 8.0] + php: [8.4, 8.3, 8.2, 8.1, 8.0] dependency-version: [prefer-lowest, prefer-stable] os: [ubuntu-latest, windows-latest] diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index b661109..062537e 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -41,7 +41,7 @@ // Comments with annotation should be docblock when used on structural elements. 'comment_to_phpdoc' => true, // Remove extra spaces in a nullable typehint. - 'compact_nullable_typehint' => true, + 'compact_nullable_type_declaration' => true, // Concatenation should be spaced according configuration. 'concat_space' => ['spacing'=>'one'], // The PHP constants `true`, `false`, and `null` MUST be written using the correct casing. @@ -111,9 +111,9 @@ // Add leading `\` before function invocation to speed up resolving. 'native_function_invocation' => ['include'=>['@all','trans']], // Native type hints for functions should use the correct case. - 'native_function_type_declaration_casing' => true, - // All instances created with new keyword must be followed by braces. - 'new_with_braces' => true, + 'native_type_declaration_casing' => true, + // All instances created with new keyword must be followed by parentheses. + 'new_with_parentheses' => true, // Master functions shall be used instead of aliases. 'no_alias_functions' => true, // Master language constructs shall be used instead of aliases. @@ -156,8 +156,8 @@ 'no_trailing_whitespace_in_comment' => true, // Removes unneeded parentheses around control statements. 'no_unneeded_control_parentheses' => true, - // Removes unneeded curly braces that are superfluous and aren't part of a control structure's body. - 'no_unneeded_curly_braces' => true, + // Removes unneeded braces that are superfluous and aren't part of a control structure's body. + 'no_unneeded_braces' => true, // A `final` class must not have `final` methods and `private` methods must not be `final`. 'no_unneeded_final_method' => true, // In function arguments there must not be arguments with default values before non-default ones. diff --git a/composer.json b/composer.json index 6edd148..cd40b64 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ }, "require": { - "php": ">=8.0 <8.4" + "php": ">=8.0 <8.5" }, "require-dev": { "phpunit/phpunit": ">=9.0", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 56bd7e0..d76da31 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,6 +5,11 @@ processIsolation="false" stopOnFailure="false" bootstrap="./tests/bootstrap.php" + displayDetailsOnTestsThatTriggerDeprecations="true" + displayDetailsOnTestsThatTriggerErrors="true" + displayDetailsOnTestsThatTriggerNotices="true" + displayDetailsOnTestsThatTriggerWarnings="true" + backupStaticProperties="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"> From 618d9c2960c3a25f7534f048b193673681644457 Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Mon, 17 Feb 2025 14:42:07 -0500 Subject: [PATCH 13/20] PhpStan (#140) * Update to PHPStan 2.0 * Update to PHPUnit 12 * Remove prefer-lowest from test config * Drop 8.0 testing due to PHPUnit issues --- .github/workflows/tests.yml | 6 +++--- composer.json | 2 +- src/NXP/Classes/Calculator.php | 1 + src/NXP/Classes/Operator.php | 2 +- src/NXP/Classes/Tokenizer.php | 1 + src/NXP/MathExecutor.php | 3 ++- tests/MathTest.php | 25 +++++++++---------------- 7 files changed, 18 insertions(+), 22 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 95f33a1..b3626f0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,8 +8,8 @@ jobs: strategy: fail-fast: false matrix: - php: [8.4, 8.3, 8.2, 8.1, 8.0] - dependency-version: [prefer-lowest, prefer-stable] + php: [8.4, 8.3, 8.2, 8.1] + dependency-version: [prefer-stable] os: [ubuntu-latest, windows-latest] name: ${{ matrix.os }} - PHP${{ matrix.php }} - ${{ matrix.dependency-version }} @@ -29,7 +29,7 @@ jobs: - name: Install dependencies run: | composer install --no-interaction - composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest + composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction - name: Execute tests run: vendor/bin/phpunit diff --git a/composer.json b/composer.json index cd40b64..8c15c52 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "require-dev": { "phpunit/phpunit": ">=9.0", "friendsofphp/php-cs-fixer": "^3.8", - "phpstan/phpstan": "^1.9" + "phpstan/phpstan": "^2.0" }, "autoload": { "psr-4": { diff --git a/src/NXP/Classes/Calculator.php b/src/NXP/Classes/Calculator.php index 80dee36..9ab4f31 100644 --- a/src/NXP/Classes/Calculator.php +++ b/src/NXP/Classes/Calculator.php @@ -1,4 +1,5 @@ assertIsArray($calculator->getFunctions()); + $this->assertIsArray($calculator->getFunctions()); // @phpstan-ignore-line } public function testGetFunctionsReturnsFunctions() : void @@ -988,7 +983,7 @@ public function testGetFunctionsReturnsFunctions() : void public function testGetVarsReturnsArray() : void { $calculator = new MathExecutor(); - $this->assertIsArray($calculator->getVars()); + $this->assertIsArray($calculator->getVars()); // @phpstan-ignore-line } public function testGetVarsReturnsCount() : void @@ -1098,9 +1093,7 @@ public function testVarExists() : void $this->assertFalse($calculator->varExists('Lucy')); } - /** - * @dataProvider providerExpressionValues - */ + #[DataProvider('providerExpressionValues')] public function testCalculatingValues(string $expression, mixed $value) : void { $calculator = new MathExecutor(); @@ -1160,7 +1153,7 @@ public function testCache() : void $calculator = new MathExecutor(); $this->assertEquals(256, $calculator->execute('2 ^ 8')); // second arg $cache is true by default - $this->assertIsArray($calculator->getCache()); + $this->assertIsArray($calculator->getCache()); // @phpstan-ignore-line $this->assertCount(1, $calculator->getCache()); $this->assertEquals(512, $calculator->execute('2 ^ 9', true)); @@ -1179,7 +1172,7 @@ public function testCache() : void public function testUnsupportedOperands() : void { - if (\version_compare(PHP_VERSION, '8') >= 0) { + if (\version_compare(PHP_VERSION, '8.0') >= 0) { // @phpstan-ignore-line $calculator = new MathExecutor(); $calculator->setVar('stringVar', 'string'); From 377daea0ac357c7a036d2a154270deaa4aa080ca Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Thu, 26 Jun 2025 14:30:50 -0400 Subject: [PATCH 14/20] PHPStan and docs updates (#142) * Update to PHPStan 2.0 * Update to PHPUnit 12 * Remove prefer-lowest from test config * Drop 8.0 testing due to PHPUnit issues * Update addOperator docs * trying to fix PHPUnit failures that work locally --- README.md | 15 +++++---------- composer.json | 2 +- tests/MathTest.php | 11 +++++------ 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8ed00a7..5b25df3 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ $executor->execute('average(1, 3, 4, 8)'); // 4 ## Operators: Default operators: `+ - * / % ^` -Add custom operator to executor: +Add custom float modulo operator to executor: ```php use NXP\Classes\Operator; @@ -132,13 +132,9 @@ $executor->addOperator(new Operator( '%', // Operator sign false, // Is right associated operator 180, // Operator priority - function (&$stack) + function ($op1, $op2) { - $op2 = array_pop($stack); - $op1 = array_pop($stack); - $result = $op1->getValue() % $op2->getValue(); - - return $result; + return fmod($op1, $op2); } )); ``` @@ -198,8 +194,7 @@ $executor->setVarValidationHandler(function (string $name, $variable) { You can dynamically define variables at run time. If a variable has a high computation cost, but might not be used, then you can define an undefined variable handler. It will only get called when the variable is used, rather than having to always set it initially. ```php -$calculator = new MathExecutor(); -$calculator->setVarNotFoundHandler( +$executor->setVarNotFoundHandler( function ($varName) { if ($varName == 'trans') { return transmogrify(); @@ -228,7 +223,7 @@ echo $executor->setDivisionByZeroIsZero()->execute('1/0'); ``` If you want another behavior, you can override division operator: ```php -$executor->addOperator("/", false, 180, function($a, $b) { +$executor->addOperator(new Operator("/"m, false, 180, function($a, $b) { if ($b == 0) { return null; } diff --git a/composer.json b/composer.json index 8c15c52..c4341b6 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "php": ">=8.0 <8.5" }, "require-dev": { - "phpunit/phpunit": ">=9.0", + "phpunit/phpunit": ">=10.0", "friendsofphp/php-cs-fixer": "^3.8", "phpstan/phpstan": "^2.0" }, diff --git a/tests/MathTest.php b/tests/MathTest.php index 5ada640..46c8988 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -20,11 +20,10 @@ use NXP\Exception\UnknownVariableException; use NXP\MathExecutor; use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\Attributes\DataProvider; class MathTest extends TestCase { - #[DataProvider('providerExpressions')] + #[\PHPUnit\Framework\Attributes\DataProvider('providerExpressions')] public function testCalculating(string $expression) : void { $calculator = new MathExecutor(); @@ -268,7 +267,7 @@ public static function providerExpressions() ]; } - #[DataProvider('bcMathExpressions')] + #[\PHPUnit\Framework\Attributes\DataProvider('bcMathExpressions')] public function testBCMathCalculating(string $expression, string $expected = '') : void { $calculator = new MathExecutor(); @@ -512,7 +511,7 @@ public static function bcMathExpressions() ]; } - #[DataProvider('incorrectExpressions')] + #[\PHPUnit\Framework\Attributes\DataProvider('incorrectExpressions')] public function testIncorrectExpressionException(string $expression) : void { $calculator = new MathExecutor(); @@ -1093,7 +1092,7 @@ public function testVarExists() : void $this->assertFalse($calculator->varExists('Lucy')); } - #[DataProvider('providerExpressionValues')] + #[\PHPUnit\Framework\Attributes\DataProvider('providerExpressionValues')] public function testCalculatingValues(string $expression, mixed $value) : void { $calculator = new MathExecutor(); @@ -1172,7 +1171,7 @@ public function testCache() : void public function testUnsupportedOperands() : void { - if (\version_compare(PHP_VERSION, '8.0') >= 0) { // @phpstan-ignore-line + if (\version_compare(PHP_VERSION, '8.0') >= 0) { /** @phpstan-ignore-line */ $calculator = new MathExecutor(); $calculator->setVar('stringVar', 'string'); From c143323b1be00c2686fc9d148444fc4c71e02f08 Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Fri, 27 Jun 2025 10:18:27 -0400 Subject: [PATCH 15/20] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b25df3..753d500 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ echo $executor->setDivisionByZeroIsZero()->execute('1/0'); ``` If you want another behavior, you can override division operator: ```php -$executor->addOperator(new Operator("/"m, false, 180, function($a, $b) { +$executor->addOperator(new Operator("/", false, 180, function($a, $b) { if ($b == 0) { return null; } From 235deeb110839ff643d0e0804c4d362462b13550 Mon Sep 17 00:00:00 2001 From: Bruce Wells Date: Fri, 7 Nov 2025 10:11:54 -0500 Subject: [PATCH 16/20] PHP 8.5 (#144) --- .php-cs-fixer.php | 4 ++-- composer.json | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 062537e..baf983a 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -1,6 +1,7 @@ setUnsupportedPhpVersionAllowed(true); $config ->setRiskyAllowed(true) @@ -264,8 +265,7 @@ 'trim_array_spaces' => true, // Unary operators should be placed adjacent to their operands. 'unary_operator_spaces' => true, - // Visibility MUST be declared on all properties and methods; `abstract` and `final` MUST be declared before the visibility; `static` MUST be declared after the visibility. - 'visibility_required' => true, + 'modifier_keywords' => ['elements' => ['const', 'method', 'property']], // Add `void` return type to functions with missing or empty return statements, but priority is given to `@return` annotations. Requires PHP >= 7.1. 'void_return' => true, // In array declaration, there MUST be a whitespace after each comma. diff --git a/composer.json b/composer.json index c4341b6..c04562b 100644 --- a/composer.json +++ b/composer.json @@ -28,12 +28,12 @@ }, "require": { - "php": ">=8.0 <8.5" + "php": ">=8.0 <8.6" }, "require-dev": { "phpunit/phpunit": ">=10.0", - "friendsofphp/php-cs-fixer": "^3.8", - "phpstan/phpstan": "^2.0" + "friendsofphp/php-cs-fixer": "*", + "phpstan/phpstan": "*" }, "autoload": { "psr-4": { From 5077e2d68cd468b29b0836cb5d79a65c563e8a9c Mon Sep 17 00:00:00 2001 From: mgiannopoulos24 <79588074+mgiannopoulos24@users.noreply.github.com> Date: Sun, 17 May 2026 06:01:52 +0300 Subject: [PATCH 17/20] Added PHP 8.5 on version list (#145) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b3626f0..061ce80 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - php: [8.4, 8.3, 8.2, 8.1] + php: [8.5, 8.4, 8.3, 8.2, 8.1] dependency-version: [prefer-stable] os: [ubuntu-latest, windows-latest] From 16e514daf7dfacd7e3c4757367e2d6cf1d82777f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 10:19:01 +0000 Subject: [PATCH 18/20] 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..48e8d2d 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', 'SC'); + $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', 'SC'); + + // 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('SC', $calculator->execute('+rating')); + } + + public function testNonNumericHandler() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'SC'); + $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', 'SC'); + + 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) => 'SC' === $value ? 10 : 0); + $calculator->setVar('rating', 'SC'); + $calculator->setVar('other', 'DC'); + + $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', 'SC'); + + $this->expectException(MathExecutorException::class); + $this->expectExceptionMessage('Value (SC) 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', 'SC'); + + $this->assertEquals(true, $calculator->execute("rating == 'SC'")); + $this->assertEquals(false, $calculator->execute("rating != 'SC'")); + $this->assertEquals(true, $calculator->execute("rating != 'DC'")); + $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', 'SC'); + $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', 'SC'); + + $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', 'SC'); + + $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', 'SC'); + + $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', 'SC'); + + $this->expectException(DivisionByZeroException::class); + $calculator->execute('2 / rating'); + } } From d87014f49b7dc7f1adf237c83938a031f1901171 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 10:47:58 +0000 Subject: [PATCH 19/20] 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 48e8d2d..8edf395 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 5eb3ccb25edcf4027fed76b6112dce00e08f718e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 10:49:06 +0000 Subject: [PATCH 20/20] 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 == 'SC' 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 8edf395..ecfc6c0 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', 'SC'); + + // 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', 'SC'); + + $this->assertEquals(0, $calculator->execute('2 % rating')); + $this->assertEquals(0, $calculator->execute('2 / rating')); + } + public function testNonNumericHandlerWithBCMathDivisionByNonNumeric() : void { $calculator = new MathExecutor();