diff --git a/application/Espo/Core/Formula/EvaluatedArgumentList.php b/application/Espo/Core/Formula/EvaluatedArgumentList.php new file mode 100644 index 0000000000..91796c7b3b --- /dev/null +++ b/application/Espo/Core/Formula/EvaluatedArgumentList.php @@ -0,0 +1,183 @@ + + * @implements Iterator + * @implements SeekableIterator + */ +class EvaluatedArgumentList implements Iterator, Countable, ArrayAccess, SeekableIterator +{ + private int $position = 0; + + /** + * @param mixed[] $dataList + */ + public function __construct(private array $dataList) + {} + + /** + * @return int + */ + private function getLastValidKey() + { + $keys = array_keys($this->dataList); + + $i = end($keys); + + while ($i > 0) { + if (array_key_exists($i, $this->dataList)) { + break; + } + + $i--; + } + + return $i; + } + + public function rewind(): void + { + $this->position = 0; + + while (!$this->valid() && $this->position <= $this->getLastValidKey()) { + $this->position ++; + } + } + + private function getArgumentByIndex(int $index): mixed + { + return $this->dataList[$index]; + } + + /** + * @return mixed + */ + #[\ReturnTypeWillChange] + public function current() + { + return $this->getArgumentByIndex($this->position); + } + + /** + * @return mixed + */ + #[\ReturnTypeWillChange] + public function key() + { + return $this->position; + } + + public function next(): void + { + do { + $this->position ++; + $next = false; + + if ( + !$this->valid() && + $this->position <= $this->getLastValidKey() + ) { + $next = true; + } + } while ($next); + } + + public function valid(): bool + { + return array_key_exists($this->position, $this->dataList); + } + + /** + * @param mixed $offset + */ + public function offsetExists($offset): bool + { + return array_key_exists($offset, $this->dataList); + } + + /** + * @param mixed $offset + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + if (!$this->offsetExists($offset)) { + return null; + } + + return $this->getArgumentByIndex($offset); + } + + /** + * @param mixed $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void + { + throw new BadMethodCallException('Setting is not allowed.'); + } + + /** + * @param mixed $offset + */ + public function offsetUnset($offset): void + { + throw new BadMethodCallException('Unsetting is not allowed.'); + } + + public function count(): int + { + return count($this->dataList); + } + + /** + * @param int $offset + */ + public function seek($offset): void + { + $this->position = $offset; + + if (!$this->valid()) { + throw new OutOfBoundsException("Invalid seek offset ($offset)."); + } + } +} diff --git a/application/Espo/Core/Formula/Evaluator.php b/application/Espo/Core/Formula/Evaluator.php index a27be5b5e8..b317fa7f89 100644 --- a/application/Espo/Core/Formula/Evaluator.php +++ b/application/Espo/Core/Formula/Evaluator.php @@ -30,7 +30,10 @@ namespace Espo\Core\Formula; use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\ExecutionException; use Espo\Core\Formula\Exceptions\SyntaxError; +use Espo\Core\Formula\Functions\Base as DeprecatedBaseFunction; +use Espo\Core\Formula\Functions\BaseFunction; use Espo\Core\Formula\Parser\Ast\Attribute; use Espo\Core\Formula\Parser\Ast\Node; use Espo\Core\Formula\Parser\Ast\Value; @@ -45,32 +48,31 @@ use stdClass; */ class Evaluator { - /** @var array */ - private $functionClassNameMap; private Parser $parser; private AttributeFetcher $attributeFetcher; - private InjectableFactory $injectableFactory; /** @var array */ private $parsedHash; /** - * @param array $functionClassNameMap + * @param array> $functionClassNameMap */ - public function __construct(InjectableFactory $injectableFactory, array $functionClassNameMap = []) - { + public function __construct( + private InjectableFactory $injectableFactory, + private array $functionClassNameMap = [] + ) { $this->attributeFetcher = $injectableFactory->create(AttributeFetcher::class); - $this->injectableFactory = $injectableFactory; - $this->functionClassNameMap = $functionClassNameMap; $this->parser = new Parser(); $this->parsedHash = []; } /** - * @return mixed + * Process expression. + * * @throws SyntaxError * @throws Error + * @throws ExecutionException */ - public function process(string $expression, ?Entity $entity = null, ?stdClass $variables = null) + public function process(string $expression, ?Entity $entity = null, ?stdClass $variables = null): mixed { $processor = new Processor( $this->injectableFactory, @@ -81,9 +83,7 @@ class Evaluator ); $item = $this->getParsedExpression($expression); - $result = $processor->process($item); - $this->attributeFetcher->resetRuntimeCache(); return $result; diff --git a/application/Espo/Core/Formula/Exceptions/BadArgumentType.php b/application/Espo/Core/Formula/Exceptions/BadArgumentType.php index 8e9d59ea97..9cafe183c2 100644 --- a/application/Espo/Core/Formula/Exceptions/BadArgumentType.php +++ b/application/Espo/Core/Formula/Exceptions/BadArgumentType.php @@ -29,6 +29,34 @@ namespace Espo\Core\Formula\Exceptions; +/** + * A bad argument type. + */ class BadArgumentType extends Error { + private ?int $position = null; + private ?string $type = null; + + /** + * Create. + * + * @param int $position An argument position. + * @param string $type A required argument type. + */ + public static function create(int $position, string $type): self + { + $obj = new self(); + $obj->position = $position; + $obj->type = $type; + + return $obj; + } + + public function getLogMessage(): string + { + $position = (string) ($this->position ?? '?'); + $type = $this->type ?? '?'; + + return "Bad argument type on position {$position}, must be {$type}."; + } } diff --git a/application/Espo/Core/Formula/Exceptions/BadArgumentValue.php b/application/Espo/Core/Formula/Exceptions/BadArgumentValue.php index 2d7f8fcf83..87181b3a58 100644 --- a/application/Espo/Core/Formula/Exceptions/BadArgumentValue.php +++ b/application/Espo/Core/Formula/Exceptions/BadArgumentValue.php @@ -29,6 +29,30 @@ namespace Espo\Core\Formula\Exceptions; +/** + * A bad argument value. + */ class BadArgumentValue extends Error { + private ?int $position = null; + + /** + * Create. + * + * @param int $position An argument position. + */ + public static function create(int $position): self + { + $obj = new self(); + $obj->position = $position; + + return $obj; + } + + public function getLogMessage(): string + { + $position = (string) ($this->position ?? '?'); + + return "Bad argument value on position {$position}."; + } } diff --git a/application/Espo/Core/Formula/Exceptions/TooFewArguments.php b/application/Espo/Core/Formula/Exceptions/TooFewArguments.php index 1496f3e508..60c811e8ce 100644 --- a/application/Espo/Core/Formula/Exceptions/TooFewArguments.php +++ b/application/Espo/Core/Formula/Exceptions/TooFewArguments.php @@ -29,6 +29,30 @@ namespace Espo\Core\Formula\Exceptions; +/** + * Too few function arguments passsed. + */ class TooFewArguments extends Error { + private ?int $number = null; + + /** + * Create. + * + * @param int $number A required number of arguments. + */ + public static function create(int $number): self + { + $obj = new self(); + $obj->number = $number; + + return $obj; + } + + public function getLogMessage(): string + { + $number = (string) ($this->number ?? '?'); + + return "Too few arguments passed, must be {$number}."; + } } diff --git a/application/Espo/Core/Formula/Func.php b/application/Espo/Core/Formula/Func.php new file mode 100644 index 0000000000..a60b92672f --- /dev/null +++ b/application/Espo/Core/Formula/Func.php @@ -0,0 +1,46 @@ + */ + /** @var array> */ private $classNameMap; /** - * @param array $classNameMap + * @param array> $classNameMap */ public function __construct( - Processor $processor, - InjectableFactory $injectableFactory, - AttributeFetcher $attributeFetcher, + private Processor $processor, + private InjectableFactory $injectableFactory, + private AttributeFetcher $attributeFetcher, ?array $classNameMap = null ) { - $this->processor = $processor; - $this->injectableFactory = $injectableFactory; - $this->attributeFetcher = $attributeFetcher; $this->classNameMap = $classNameMap ?? []; } /** - * @return BaseFunction|Base * @throws UnknownFunction */ - public function create(string $name, ?Entity $entity = null, ?stdClass $variables = null): object + public function create(string $name, ?Entity $entity = null, ?stdClass $variables = null): Func|BaseFunction|Base { if ($this->classNameMap && array_key_exists($name, $this->classNameMap)) { $className = $this->classNameMap[$name]; @@ -83,7 +77,7 @@ class FunctionFactory $typeName = implode('\\', $arr); - /** @var class-string $className */ + /** @var class-string $className */ $className = 'Espo\\Core\\Formula\\Functions\\' . $typeName . 'Type'; } @@ -91,6 +85,10 @@ class FunctionFactory throw new UnknownFunction("Unknown function: " . $name); } + if ((new ReflectionClass($className))->implementsInterface(Func::class)) { + return $this->injectableFactory->create($className); + } + $object = $this->injectableFactory->createWith($className, [ 'name' => $name, 'processor' => $this->processor, diff --git a/application/Espo/Core/Formula/Functions/BaseFunction.php b/application/Espo/Core/Formula/Functions/BaseFunction.php index 83ce1c0307..f7e875eb97 100644 --- a/application/Espo/Core/Formula/Functions/BaseFunction.php +++ b/application/Espo/Core/Formula/Functions/BaseFunction.php @@ -163,7 +163,6 @@ abstract class BaseFunction * @throws Error */ protected function throwError(?string $msg = null) - { $string = 'function: ' . $this->name; diff --git a/application/Espo/Core/Formula/Functions/StringGroup/ContainsType.php b/application/Espo/Core/Formula/Functions/StringGroup/ContainsType.php index 48bbe48b86..5241f7f888 100644 --- a/application/Espo/Core/Formula/Functions/StringGroup/ContainsType.php +++ b/application/Espo/Core/Formula/Functions/StringGroup/ContainsType.php @@ -29,23 +29,20 @@ namespace Espo\Core\Formula\Functions\StringGroup; -use Espo\Core\Formula\{ - Functions\BaseFunction, - ArgumentList, -}; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; -class ContainsType extends BaseFunction +class ContainsType implements Func { - public function process(ArgumentList $args) + public function process(EvaluatedArgumentList $arguments): bool { - $args = $this->evaluate($args); - - if (count($args) < 2) { - $this->throwTooFewArguments(); + if (count($arguments) < 2) { + throw TooFewArguments::create(2); } - $string = $args[0]; - $needle = $args[1]; + $string = $arguments[0]; + $needle = $arguments[1]; if (!is_string($string)) { return false; diff --git a/application/Espo/Core/Formula/Functions/UtilGroup/GenerateIdType.php b/application/Espo/Core/Formula/Functions/UtilGroup/GenerateIdType.php index 3a46bd4c66..0dcbb712ac 100644 --- a/application/Espo/Core/Formula/Functions/UtilGroup/GenerateIdType.php +++ b/application/Espo/Core/Formula/Functions/UtilGroup/GenerateIdType.php @@ -29,18 +29,13 @@ namespace Espo\Core\Formula\Functions\UtilGroup; -use Espo\Core\Formula\{ - ArgumentList, -}; - +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Func; use Espo\Core\Utils\Util; -class GenerateIdType +class GenerateIdType implements Func { - /** - * @return string - */ - public function process(ArgumentList $args) + public function process(EvaluatedArgumentList $arguments): string { return Util::generateId(); } diff --git a/application/Espo/Core/Formula/Processor.php b/application/Espo/Core/Formula/Processor.php index b0e6121e30..b9b0274dbb 100644 --- a/application/Espo/Core/Formula/Processor.php +++ b/application/Espo/Core/Formula/Processor.php @@ -29,16 +29,20 @@ namespace Espo\Core\Formula; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\BadArgumentValue; use Espo\Core\Formula\Exceptions\ExecutionException; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Functions\Base as DeprecatedBaseFunction; +use Espo\Core\Formula\Functions\BaseFunction; use Espo\Core\Formula\Parser\Ast\Attribute; use Espo\Core\Formula\Parser\Ast\Node; use Espo\Core\Formula\Parser\Ast\Value; use Espo\Core\Formula\Parser\Ast\Variable; -use Espo\ORM\Entity; - use Espo\Core\Formula\Exceptions\Error; use Espo\Core\InjectableFactory; -use Espo\Core\Formula\Functions\Base as DeprecatedBaseFunction; + +use Espo\ORM\Entity; use InvalidArgumentException; use stdClass; @@ -49,17 +53,16 @@ use stdClass; class Processor { private FunctionFactory $functionFactory; - private ?Entity $entity; private stdClass $variables; /** - * @param ?array $functionClassNameMap + * @param ?array> $functionClassNameMap */ public function __construct( InjectableFactory $injectableFactory, AttributeFetcher $attributeFetcher, ?array $functionClassNameMap = null, - ?Entity $entity = null, + private ?Entity $entity = null, ?stdClass $variables = null ) { $this->functionFactory = new FunctionFactory( @@ -69,7 +72,6 @@ class Processor $functionClassNameMap ); - $this->entity = $entity; $this->variables = $variables ?? (object) []; } @@ -80,7 +82,7 @@ class Processor * @throws Error * @throws ExecutionException */ - public function process(Evaluatable $item) + public function process(Evaluatable $item): mixed { if ($item instanceof ArgumentList) { return $this->processList($item); @@ -92,7 +94,21 @@ class Processor $function = $this->functionFactory->create($item->getType(), $this->entity, $this->variables); - /** @deprecated */ + if ($function instanceof Func) { + $evaluatedArguments = array_map(function ($item) { + return $this->process($item); + }, iterator_to_array($item->getArgumentList())); + + try { + return $function->process(new EvaluatedArgumentList($evaluatedArguments)); + } + catch (TooFewArguments|BadArgumentType|BadArgumentValue $e) { + $message = sprintf('Function %s; %s', $item->getType(), $e->getLogMessage()); + + throw new Error($message); + } + } + if ($function instanceof DeprecatedBaseFunction) { return $function->process(self::dataToStdClass($item->getData())); } @@ -139,6 +155,7 @@ class Processor /** * @return mixed[] * @throws Error + * @throws ExecutionException */ private function processList(ArgumentList $args): array { diff --git a/tests/unit/Espo/Core/Formula/EvaluatorTest.php b/tests/unit/Espo/Core/Formula/EvaluatorTest.php index c91e9e5b6a..65634c2e48 100644 --- a/tests/unit/Espo/Core/Formula/EvaluatorTest.php +++ b/tests/unit/Espo/Core/Formula/EvaluatorTest.php @@ -30,6 +30,7 @@ namespace tests\unit\Espo\Core\Formula; use Espo\Core\Formula\Evaluator; +use Espo\Core\Formula\Exceptions\Error; use Espo\Core\InjectableFactory; use Espo\Core\Formula\Exceptions\SyntaxError; use Espo\Core\Utils\Log; @@ -1153,4 +1154,31 @@ class EvaluatorTest extends \PHPUnit\Framework\TestCase $this->assertEquals(2, $value); } + + public function testFuncInterface1(): void + { + $expression = "string\\contains(string\\concatenate('test', 'hello'), 'hello')"; + + $value = $this->evaluator->process($expression); + + $this->assertEquals(true, $value); + } + + public function testFuncInterface2(): void + { + $expression = "true == string\\contains('test hello', 'hello')"; + + $value = $this->evaluator->process($expression); + + $this->assertEquals(true, $value); + } + + public function testFuncInterfaceException(): void + { + $expression = "true == string\\contains('test hello')"; + + $this->expectException(Error::class); + + $this->evaluator->process($expression); + } }