Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
* 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
* Easily extensible
Expand Down Expand Up @@ -209,15 +210,15 @@ 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');
} catch (DivisionByZeroException $e) {
echo $e->getMessage();
}
```
Or call setDivisionByZeroIsZero
Or call setDivisionByZeroIsZero, which covers both `/` and `%`
```php
echo $executor->setDivisionByZeroIsZero()->execute('1/0');
```
Expand All @@ -232,6 +233,46 @@ $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()`. 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.

Expand Down
197 changes: 176 additions & 21 deletions src/NXP/MathExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ class MathExecutor
*/
protected $onVarValidation = null;

/**
* @var callable|null
*/
protected $onNonNumeric = null;

/**
* @var Operator[]
*/
Expand Down Expand Up @@ -228,6 +233,28 @@ 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, 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.
*
* @param ?callable $handler callable(mixed $value, string $operator): mixed
*
*/
public function setNonNumericHandler(?callable $handler) : self
{
$this->onNonNumeric = $handler;

return $this;
}

/**
* Remove variable from executor
*
Expand Down Expand Up @@ -282,11 +309,22 @@ 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
{
$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;
}));
$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;
}
Expand All @@ -313,20 +351,56 @@ 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, '%');

if (0 == $b) {
throw new DivisionByZeroException();
}

return \bcmod("{$a}", "{$b}");
}));

return $this;
}
Expand Down Expand Up @@ -360,16 +434,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();
}
Expand All @@ -379,16 +456,61 @@ 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],
'%' => [
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],
'!=' => [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],
'>=' => [
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],
];
}
Expand Down Expand Up @@ -534,6 +656,39 @@ 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, 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) || \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
Expand Down
Loading
Loading