formula func interface

This commit is contained in:
Yuri Kuznetsov
2023-02-17 14:01:06 +02:00
parent 854f6c6390
commit 75749efacb
12 changed files with 396 additions and 57 deletions

View File

@@ -0,0 +1,183 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* EspoCRM is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* EspoCRM is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with EspoCRM. If not, see http://www.gnu.org/licenses/.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU General Public License version 3.
*
* In accordance with Section 7(b) of the GNU General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Formula;
use BadMethodCallException;
use OutOfBoundsException;
use Iterator;
use Countable;
use ArrayAccess;
use SeekableIterator;
/**
* A list of evaluated function arguments.
*
* @implements ArrayAccess<int, mixed>
* @implements Iterator<mixed>
* @implements SeekableIterator<int, mixed>
*/
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).");
}
}
}

View File

@@ -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<string, class-string> */
private $functionClassNameMap;
private Parser $parser;
private AttributeFetcher $attributeFetcher;
private InjectableFactory $injectableFactory;
/** @var array<string, (Node|Value|Attribute|Variable)> */
private $parsedHash;
/**
* @param array<string, class-string> $functionClassNameMap
* @param array<string, class-string<BaseFunction|Func|DeprecatedBaseFunction>> $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;

View File

@@ -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}.";
}
}

View File

@@ -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}.";
}
}

View File

@@ -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}.";
}
}

View File

@@ -0,0 +1,46 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* EspoCRM is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* EspoCRM is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with EspoCRM. If not, see http://www.gnu.org/licenses/.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU General Public License version 3.
*
* In accordance with Section 7(b) of the GNU General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Formula;
use Espo\Core\Formula\Exceptions\Error;
/**
* A function.
*/
interface Func
{
/**
* Process.
*
* @param EvaluatedArgumentList $arguments
* @throws Error
*/
public function process(EvaluatedArgumentList $arguments): mixed;
}

View File

@@ -36,36 +36,30 @@ use Espo\Core\Formula\Functions\BaseFunction;
use Espo\ORM\Entity;
use Espo\Core\InjectableFactory;
use ReflectionClass;
use stdClass;
class FunctionFactory
{
private Processor $processor;
private InjectableFactory $injectableFactory;
private AttributeFetcher $attributeFetcher;
/** @var array<string, class-string> */
/** @var array<string, class-string<BaseFunction|Func|Base>> */
private $classNameMap;
/**
* @param array<string,class-string> $classNameMap
* @param array<string, class-string<BaseFunction|Func|Base>> $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<BaseFunction|Base> $className */
/** @var class-string<Func|BaseFunction|Base> $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,

View File

@@ -163,7 +163,6 @@ abstract class BaseFunction
* @throws Error
*/
protected function throwError(?string $msg = null)
{
$string = 'function: ' . $this->name;

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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<string, class-string> $functionClassNameMap
* @param ?array<string, class-string<BaseFunction|Func|DeprecatedBaseFunction>> $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
{

View File

@@ -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);
}
}