Skip to content

Add a non-numeric value handler for arithmetic operators - #146

Open
javiermarinros wants to merge 3 commits into
neonxp:masterfrom
ideatic:claude/math-executor-non-numeric-handler-pw0e97
Open

Add a non-numeric value handler for arithmetic operators#146
javiermarinros wants to merge 3 commits into
neonxp:masterfrom
ideatic:claude/math-executor-non-numeric-handler-pw0e97

Conversation

@javiermarinros

Copy link
Copy Markdown
Contributor

The bug

Arithmetic operators hand their operands straight to PHP, so a value that is not a number leaks a raw \TypeError out of the library instead of one of its own NXP\Exception\* types:

TypeError: Unsupported operand types: string / string
  at NXP\MathExecutor::{closure:NXP\MathExecutor::defaultOperators():371}()
  at NXP\Classes\Operator->execute()
  at NXP\Classes\Calculator->calculate()

useBCMath() leaks a ValueError from bcdiv() the same way. A caller cannot catch library errors reliably, because the library's own API throws types that are not part of it. tests/MathTest.php::testUnsupportedOperands already pins this behaviour today.

This is not an exotic case. Real data has non-numeric values in otherwise numeric columns: a rating stored as 'N/A' when nothing was rated, a '' for a missing measurement, a category code where a number is expected. is_numeric('') is false, so the empty string hits it too.

And there is currently no supported way to say what a non-numeric operand should mean. The only workaround is to re-register every arithmetic operator with addOperator(), duplicating the library's own definitions — including the DivisionByZeroException behaviour and the BCMath variants.

The fix

A handler, in the spirit of the existing setVarNotFoundHandler() / setVarValidationHandler() / setDivisionByZeroIsZero() opt-ins — not a boolean, so the caller decides what a non-numeric value means (zero, a domain-specific mapping, a logged warning, or an exception of their own):

public function setNonNumericHandler(?callable $handler) : self
$executor->setNonNumericHandler(fn($value, string $operator) => 0);
$executor->setVar('rating', 'N/A');
echo $executor->execute('rating / 2'); // 0

The handler receives the value and the operator name ('+', '/', 'uNeg', ...), so it can react differently per operator, and its return value is used in place of the operand. Throwing from it converts the \TypeError into an error of the caller's own type:

$executor->setNonNumericHandler(function ($value, string $operator) {
    throw new MathExecutorException("Value ({$value}) is not a number, required by operator ({$operator})");
});

Scope, deliberately narrow:

  • Applied to the operators that require a number — +, -, *, /, %, ^, uNeg, uPos, >, >=, <, <= — in defaultOperators(), and to the same operators as re-registered by setDivisionByZeroIsZero() and useBCMath().
  • Not applied to ==, !=, &&, ||, !. Those already have meaningful string/boolean semantics (== and != do a strcmp when either side is a string) and changing them would break existing users.
  • Never called for values that are numeric ('3' included), null, boolean or array. Arrays are a supported variable type — defaultVarValidation() accepts them and avg()/min()/max() consume them — and PHP gives + a defined meaning for them, so [1, 2] + [3, 4] keeps returning the array union.
  • Not applied when an ordering operator compares two non-numeric values. That is a string comparison, which is meaningful and consistent with ==/!=, so 'apple' < 'banana' is still true with a handler installed. The handler applies to >/>=/</<= only when the other side is a number — the case this feature is about, where PHP would otherwise compare that number as a string and give 'N/A' > 1 === true.

Backwards compatibility

No behaviour changes when no handler is set, which is the default. normalizeOperand() and normalizeComparisonOperands() both return their operands untouched as their first check when $onNonNumeric is null, so every operator receives exactly what it received before.

This was verified differentially rather than asserted: 218 expression/mode combinations (arithmetic, ordering, equality, logical operators, functions, string and array and null and boolean variables, under plain / setDivisionByZeroIsZero() / useBCMath()) were evaluated against master and against this branch with no handler set. The output is identical except for the three 10 % 0 cases below.

Implementation notes that follow from the existing design:

  • Operator infers arity by reflection on the closure, so the replacement closures keep exactly the same parameter count and stay untyped (Calculator pushes raw values through call_user_func_array).
  • The affected default closures stop being static so they can read $this->onNonNumeric lazily at call time. That means the handler can be set at any point — before or after useBCMath() / setDivisionByZeroIsZero() — and it survives __clone() (which re-runs addDefaults()).
  • normalizeOperand() is protected, not private, so a subclass that overrides defaultOperators() — the extension point the README documents — can still use it.

One deliberate behaviour change: modulo by zero

% leaked PHP's raw \DivisionByZeroError out of the library instead of NXP\Exception\DivisionByZeroException, setDivisionByZeroIsZero() did not cover it, and useBCMath()'s bcmod() did the same. This is a pre-existing bug on master10 % 0 throws the PHP Error there today — but it is the same bug this PR is about, and it became visible through the handler: with the README's own => 0 handler, 2 / rating resolved through DivisionByZeroException while 2 % rating threw a PHP Error that catch (MathExecutorException) cannot catch.

% now mirrors / in all three places. The change is confined to a zero divisor:

master this branch
10 % 0 \DivisionByZeroError NXP\Exception\DivisionByZeroException
10 % 0 after setDivisionByZeroIsZero() \DivisionByZeroError 0
10 % 0 under useBCMath() \DivisionByZeroError NXP\Exception\DivisionByZeroException

Non-numeric operands with no handler are unaffected, because 0 == 'N/A' is false on PHP 8 — they still reach the operator and raise the same \TypeError as before.

This is the last commit on the branch and is self-contained, so it can be dropped if you would rather keep it to a separate PR.

Tests

tests/MathTest.php gains 18 test methods / 26 cases:

  • testNonNumericWithoutHandler (data provider, 8 arithmetic expressions) and testNonNumericComparisonWithoutHandler — assert the current behaviour is untouched with no handler: \TypeError for the arithmetic operators, string comparison for the ordering ones, 'N/A' returned by unary +.
  • testNonNumericHandler — every affected operator, including '' + 1 === 1.
  • testNonNumericHandlerReceivesTheOperator — asserts the exact operator names ['+', '-', '*', '/', '%', '^', 'uNeg', 'uPos', '>', '>=', '<', '<='].
  • testNonNumericHandlerCanReturnAnyValue, testNonNumericHandlerException (the exception propagates), testNonNumericHandlerCanBeRemoved, testNonNumericHandlerSurvivesClone.
  • testNonNumericHandlerIgnoresNumbers'3' * 2 === 6, 3 + '2.5' === 5.5, null + 1 === 1, true + 1 === 2, and the handler is never invoked.
  • testNonNumericHandlerDoesNotAffectStringOperators==, !=, &&, ||, ! unchanged.
  • testNonNumericHandlerDoesNotAffectStringOrdering — all four ordering operators on two strings, with the handler installed, and the handler is never invoked.
  • testNonNumericHandlerDoesNotAffectArrays — the array union survives and the handler is never invoked.
  • testNonNumericHandlerWithDivisionByZeroIsZero, testNonNumericHandlerWithBCMath, testNonNumericHandlerWithBCMathDivisionByNonNumeric, testNonNumericHandlerModuloByNonNumeric, testNonNumericHandlerModuloByNonNumericIsZero.
  • For the modulo fix: testZeroModuloException, testZeroModuloExceptionWithBCMath, and testZeroDivision extended with 10 % 0.

Suite goes from 455 tests / 567 assertions to 481 tests / 637 assertions, all green. PHPStan level 6 and php-cs-fixer are clean with no new ignores or baseline entries.

README gains a "Non-Numeric Value Support" section next to the Division By Zero one, a bullet in the feature list, and a note that setDivisionByZeroIsZero() covers % as well.

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018EWvvUoaHC1SaNQ23nVrCk
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018EWvvUoaHC1SaNQ23nVrCk
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018EWvvUoaHC1SaNQ23nVrCk
@phpfui

phpfui commented Sep 7, 2026 via email

Copy link
Copy Markdown
Collaborator

@javiermarinros

Copy link
Copy Markdown
Contributor Author

Thanks for the heads-up, Bruce! Safe travels and zero rush at all, we're running on our fork in the meantime, so take all the time you need. Looking forward to catching up whenever you're back.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants