Skip to content
Draft
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
2 changes: 1 addition & 1 deletion CRM/Banking/PluginImpl/Matcher/RegexAnalyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public function analyse(CRM_Banking_BAO_BankTransaction $btx, CRM_Banking_Matche
/**
* execute all the action defined by the rule to the given match
*
* @param array<int|string, list<string>> $matchData
* @param array<string, list<string>> $matchData
* Matches of preg_match_all().
*
* @throws \CRM_Core_Exception
Expand Down
107 changes: 107 additions & 0 deletions Civi/Banking/Matcher/Helper/Api4ParamsFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php
/*
* Copyright (C) 2026 SYSTOPIA GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

declare(strict_types = 1);

namespace Civi\Banking\Matcher\Helper;

use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Webmozart\Assert\Assert;

final class Api4ParamsFactory {

public function __construct(
private readonly ExpressionLanguage $expressionLanguage
) {}

/**
* @phpstan-param object{
* action: string,
* params?: \stdClass,
* result_map?: \stdClass,
* } $actionDefinition
*
* @param array<string, mixed> $expressionValues
*
* @return array<string, mixed>
*/
public function createParams(
object $actionDefinition,
array $expressionValues = []
): array {
if (property_exists($actionDefinition, 'params')) {
Assert::isInstanceOf($actionDefinition->params, \stdClass::class);
// Convert \stdClass to array.
/** @var array<string, mixed> $params */
// @phpstan-ignore argument.type
$params = json_decode(json_encode($actionDefinition->params), TRUE);
}
else {
$params = [];
}

if ('get' === $actionDefinition->action && !isset($params['select'])) {
if (property_exists($actionDefinition, 'result_map')) {
$params['select'] = [];
Assert::isInstanceOf($actionDefinition->result_map, \stdClass::class);
$resultMap = (array) $actionDefinition->result_map;
foreach ($resultMap as $source) {
if (is_string($source)) {
$fieldNameOrExpression = $source;
}
else {
Assert::notNull($source->field, 'Source field name in result map is missing');
Assert::string($source->field, 'Expected string as source field name in result map, got %s');
$fieldNameOrExpression = $source->field;
}

if (str_starts_with($fieldNameOrExpression, '@=')) {
$params['select'] = [];
break;
}

$params['select'][] = $fieldNameOrExpression;
}
}
}

self::evaluateExpressions($params, $expressionValues);

return $params;
}

/**
* Recursively evaluate and replace expressions, i.e. values starting with
* "@=".
*
* @param array<mixed> $array
* @param array<int|string, mixed> $expressionValues
*/
private function evaluateExpressions(array &$array, array $expressionValues): void {
foreach ($array as &$value) {
if (is_array($value)) {
self::evaluateExpressions($value, $expressionValues);
}
elseif (is_string($value) && str_starts_with($value, '@=')) {
$expression = substr($value, 2);
$value = $this->expressionLanguage->evaluate($expression, $expressionValues);
}
}
}

}
122 changes: 122 additions & 0 deletions Civi/Banking/Matcher/Helper/Api4ResultMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php
/*
* Copyright (C) 2026 SYSTOPIA GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

declare(strict_types = 1);

namespace Civi\Banking\Matcher\Helper;

use Civi\Api4\Generic\Result;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Webmozart\Assert\Assert;

/**
* @phpstan-type resultMapT array<string, string|object{
* field: string,
* filter?: 'first'|'last',
* value_separator?: string,
* }>
*/
final class Api4ResultMapper {

public function __construct(
private readonly ExpressionLanguage $expressionLanguage
) {}

/**
* @phpstan-param resultMapT $resultMap
* Mapping of target field name to an APIv4 field name, an expression or an
* object containing the APIv4 field name.
* @param callable(string, mixed): void $setValueCallback
*/
public function mapResult(Result $result, array $resultMap, callable $setValueCallback): void {
if (0 === $result->countFetched()) {
return;
}

foreach ($resultMap as $to => $from) {
if (!is_string($from) && !$from instanceof \stdClass) {
throw new \InvalidArgumentException(sprintf('Invalid source definition for field "%s" in result map', $to));
}

$setValueCallback($to, $this->getValue($result, $from));
}
}

private function applyFilter(mixed $value, string $filter): mixed {
if ('first' === $filter) {
if (is_array($value)) {
return [] === $value ? NULL : reset($value);
}

return $value;
}

if ('last' === $filter) {
if (is_array($value)) {
return [] === $value ? NULL : end($value);
}

return $value;
}

throw new \InvalidArgumentException(sprintf('Unknown filter "%s"', $filter));
}

private function applyModifications(mixed $value, \stdClass $source): mixed {
if (property_exists($source, 'filter')) {
Assert::string('Filter has to be a string in source definition of result map');
$value = $this->applyFilter($value, $source->filter);
}

return $value;
}

private function getValue(Result $result, string|\stdClass $source): mixed {
if (is_string($source)) {
if (str_starts_with($source, '@=')) {
return $this->getValueForExpression($result, substr($source, 2));
}

return $this->getValueForFieldName($result, $source);
}

$fieldName = $source->field;
Assert::notNull($fieldName, 'Source field name in result map is missing');
Assert::string($fieldName, 'Expected string as source field name in result map, got %s');

$valueSeparator = $source->value_separator ?? ',';
Assert::string($valueSeparator, 'Expected string as value separator in result map, got %s');

return $this->applyModifications(
$this->getValueForFieldName($result, $fieldName, $valueSeparator),
$source
);
}

private function getValueForExpression(Result $result, string $expression): mixed {
return $this->expressionLanguage->evaluate($expression, ['result' => $result]);
}

private function getValueForFieldName(Result $result, string $fieldName, string $valueSeparator = ','): mixed {
return match ($result->countFetched()) {
1 => $result->single()[$fieldName],
default => implode($valueSeparator, $result->column($fieldName)),
};
}

}
87 changes: 87 additions & 0 deletions Civi/Banking/Matcher/Helper/ExpressionLanguageValuesGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php
/*
* Copyright (C) 2026 SYSTOPIA GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

declare(strict_types = 1);

namespace Civi\Banking\Matcher\Helper;

use Webmozart\Assert\Assert;

final class ExpressionLanguageValuesGenerator {

/**
* Generates a values array for use in Symfony Expression Language to access
* values in expressions via e.g. "btx.amount" or "btx['my.value']". (For keys
* containing dots or special characters, array access has to be used.)
*
* @param list<string> $prefixes
* List of prefixes, e.g. "btx" or "ba".
* @param callable(string): mixed $getValueCallback
*
* @return array<string, object>
*/
public static function generateValuesForPrefixes(array $prefixes, callable $getValueCallback): array {
$values = [];
foreach ($prefixes as $prefix) {
$values[$prefix] = self::createValueWrapper($prefix, $getValueCallback);
}

return $values;
}

/**
* @param callable(string): mixed $getValueCallback
*/
private static function createValueWrapper(string $prefix, callable $getValueCallback): object {
return new class ($prefix, $getValueCallback(...)) implements \ArrayAccess {

/**
* @param \Closure(string): mixed $getValueCallback
*/
public function __construct(
private readonly string $prefix,
private readonly \Closure $getValueCallback,
) {}

public function __get(string $name): mixed {
return ($this->getValueCallback)($this->prefix . '.' . $name);
}

public function offsetExists(mixed $offset): bool {
Assert::string($offset);
return NULL !== ($this->getValueCallback)($this->prefix . '.' . $offset);
}

public function offsetGet(mixed $offset): mixed {
Assert::string($offset);

return ($this->getValueCallback)($this->prefix . '.' . $offset);
}

public function offsetSet(mixed $offset, mixed $value): void {
throw new \BadMethodCallException('Unsupported method');
}

public function offsetUnset(mixed $offset): void {
throw new \BadMethodCallException('Unsupported method');
}

};
}

}
Loading
Loading