Files
espocrm/custom/Espo/Modules/Advanced/Tools/Report/ReportHelper.php
2026-01-19 17:46:06 +01:00

505 lines
15 KiB
PHP

<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Formula\Exceptions\Error as FormulaError;
use Espo\Core\Formula\Manager as FormulaManager;
use Espo\Core\InjectableFactory;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\Entities\Preferences;
use Espo\Modules\Advanced\Entities\Report;
use Espo\Modules\Advanced\Reports\GridReport;
use Espo\Modules\Advanced\Reports\ListReport;
use Espo\Modules\Advanced\Tools\Report\GridType\Data as GridData;
use Espo\Modules\Advanced\Tools\Report\GridType\JointData;
use Espo\Modules\Advanced\Tools\Report\ListType\Data as ListData;
use stdClass;
class ReportHelper
{
private const WHERE_TYPE_AND = 'and';
private const WHERE_TYPE_OR = 'or';
private const WHERE_TYPE_HAVING = 'having';
private const WHERE_TYPE_NOT = 'not';
private const WHERE_TYPE_SUB_QUERY_IN = 'subQueryIn';
private const WHERE_TYPE_SUB_QUERY_NOT_IN = 'subQueryNotIn';
private const ATTR_HAVING = '_having';
public function __construct(
private Metadata $metadata,
private InjectableFactory $injectableFactory,
private FormulaManager $formulaManager,
private Config $config,
private Preferences $preferences,
private FormulaChecker $formulaChecker
) {}
/**
* @throws Error
* @return ListReport|GridReport
*/
public function createInternalReport(Report $report): object
{
$className = $report->get('internalClassName');
if ($className && stripos($className, ':') !== false) {
[$moduleName, $reportName] = explode(':', $className);
if ($moduleName === 'Custom') {
$className = "Espo\\Custom\\Reports\\$reportName";
} else {
$className = "Espo\\Modules\\$moduleName\\Reports\\$reportName";
}
}
if (!$className) {
throw new Error('No class name specified for internal report.');
}
/** @var class-string<ListReport|GridReport> $className */
return $this->injectableFactory->create($className);
}
/**
* @throws Forbidden
*/
public function checkReportCanBeRun(Report $report): void
{
if (
in_array(
$report->getTargetEntityType(),
$this->metadata->get('entityDefs.Report.entityListToIgnore', [])
)
) {
throw new Forbidden("Entity type is not allowed.");
}
}
/**
* @throws Error
* @throws Forbidden
*/
public function fetchGridDataFromReport(Report $report): GridData
{
if ($report->getType() !== Report::TYPE_GRID) {
throw new Error("Non-grid report.");
}
return new GridData(
$report->getTargetEntityType(),
$report->getColumns(),
$report->getGroupBy(),
$report->getOrderBy(),
$report->getApplyAcl(),
$this->fetchFiltersWhereFromReport($report),
$report->get('chartType'),
get_object_vars($report->get('chartColors') ?? (object) []),
$report->get('chartColor'),
$report->get('chartDataList'),
($report->get('data') ?? (object) [])->success ?? null,
$report->getColumnsData(),
);
}
/**
* @throws Error
* @throws Forbidden
*/
public function fetchListDataFromReport(Report $report): ListData
{
if ($report->getType() !== Report::TYPE_LIST) {
throw new Error("Non-list report.");
}
return new ListData(
$report->getTargetEntityType(),
$report->getColumns(),
$report->getOrderByList(),
$report->getColumnsData(),
$this->fetchFiltersWhereFromReport($report)
);
}
/**
* @throws Error
*/
public function fetchJointDataFromReport(Report $report): JointData
{
if ($report->getType() !== Report::TYPE_JOINT_GRID) {
throw new Error("Non-joint-grid report.");
}
return new JointData(
$report->get('joinedReportDataList') ?? [],
$report->get('chartType')
);
}
/**
* @throws Error
* @throws Forbidden
*/
public function fetchFiltersWhereFromReport(Report $report): ?WhereItem
{
$isNotList = $report->getType() !== Report::TYPE_LIST;
$raw = $report->get('filtersData') && !$report->get('filtersDataList') ?
$this->convertFiltersData($report->get('filtersData')) :
$this->convertFiltersDataList($report->get('filtersDataList') ?? [], $isNotList);
if (!$raw) {
return null;
}
$raw = json_decode(
/** @phpstan-ignore-next-line */
json_encode($raw),
true
);
return WhereItem::fromRawAndGroup($raw);
}
/**
* @param array<string, object{
* where?: mixed,
* field?: string,
* type?: string,
* dateTime?: string,
* value?: mixed,
* }>|null $filtersData
* @return stdClass[]|null
*/
private function convertFiltersData(?array $filtersData): ?array
{
if (empty($filtersData)) {
return null;
}
$arr = [];
foreach ($filtersData as $name => $defs) {
$field = $name;
if (empty($defs)) {
continue;
}
if (isset($defs->where)) {
$arr[] = $defs->where;
} else {
if (isset($defs->field)) {
$field = $defs->field;
}
$type = $defs->type ?? null;
if (!empty($defs->dateTime)) {
$arr[] = $this->fixDateTimeWhere(
$type,
$field,
$defs->value ?? null,
false
);
} else {
$o = new stdClass();
$o->type = $type;
$o->field = $field;
$o->value = $defs->value ?? null;
$arr[] = $o;
}
}
}
return $arr;
}
/**
* @param object{
* type?: string,
* name?: ?string,
* params?: object{
* type?: string,
* where?: mixed,
* attribute?: string,
* field?: string,
* dateTime?: string,
* value?: mixed,
* function?: string,
* expression?: string,
* operator?: string,
* },
* }[] $filtersDataList
* @return stdClass[]|null
* @throws Error
* @throws Forbidden
*/
private function convertFiltersDataList(array $filtersDataList, bool $useSystemTimeZone): ?array
{
if (empty($filtersDataList)) {
return null;
}
$arr = [];
foreach ($filtersDataList as $defs) {
$field = null;
if (isset($defs->name)) {
$field = $defs->name;
}
if (empty($defs) || empty($defs->params)) {
continue;
}
$params = $defs->params;
$type = $defs->type ?? null;
if (
in_array($type, [
self::WHERE_TYPE_OR,
self::WHERE_TYPE_AND,
self::WHERE_TYPE_NOT,
self::WHERE_TYPE_SUB_QUERY_IN,
self::WHERE_TYPE_SUB_QUERY_NOT_IN,
self::WHERE_TYPE_HAVING,
])
) {
if (empty($params->value)) {
continue;
}
$o = new stdClass();
$o->type = $params->type ?? null;
if ($o->type === self::WHERE_TYPE_NOT) {
$o->type = self::WHERE_TYPE_SUB_QUERY_NOT_IN;
}
if ($o->type === self::WHERE_TYPE_HAVING) {
$o->type = self::WHERE_TYPE_AND;
$o->attribute = self::ATTR_HAVING;
}
$o->value = $this->convertFiltersDataList($params->value, $useSystemTimeZone);
$arr[] = $o;
continue;
}
if ($type === 'complexExpression') {
$o = (object) [];
$function = $params->function ?? null;
if ($function === 'custom') {
if (empty($params->expression)) {
continue;
}
$o->attribute = $params->expression;
$o->type = 'expression';
} else if ($function === 'customWithOperator') {
if (empty($params->expression)) {
continue;
}
if (empty($params->operator)) {
continue;
}
$o->attribute = $params->expression;
$o->type = $params->operator;
} else {
if (empty($params->attribute)) {
continue;
}
if (empty($params->operator)) {
continue;
}
$o->attribute = $params->attribute;
if ($function) {
$o->attribute = $function . ':' . $o->attribute;
}
$o->type = $params->operator;
}
if (isset($params->value) && is_string($params->value) && strlen($params->value)) {
try {
$o->value = $this->runFormula($params->value);
}
catch (FormulaError $e) {
throw new Error($e->getMessage());
}
}
$arr[] = $o;
continue;
}
if (isset($params->where)) {
$arr[] = $params->where;
continue;
}
if (isset($params->field)) {
$field = $params->field;
}
if (empty($params->type)) {
continue;
}
$type = $params->type;
if (!empty($params->dateTime)) {
$arr[] = $this->fixDateTimeWhere(
$type,
$field,
$params->value ?? null,
$useSystemTimeZone
);
continue;
}
$o = new stdClass();
$o->type = $type;
$o->field = $field;
$o->attribute = $field;
$o->value = $params->value ?? null;
$arr[] = $o;
}
return $arr;
}
/**
* @param mixed $value
*/
private function fixDateTimeWhere(string $type, string $field, $value, bool $useSystemTimeZone): object
{
$timeZone = null;
if (!$useSystemTimeZone) {
$timeZone = $this->preferences->get('timeZone');
}
if (!$timeZone) {
$timeZone = $this->config->get('timeZone') ?? 'UTC';
}
return (object) [
'attribute' => $field,
'type' => $type,
'value' => $value,
'dateTime' => true,
'timeZone' => $timeZone,
];
}
/**
* @throws Forbidden
*/
public function checkRuntimeFilters(WhereItem $where, Report $report): void
{
$this->checkRuntimeFiltersItem($where, $report->getRuntimeFilters());
}
/**
* @param string[] $allowedFilterList
* @throws Forbidden
*/
private function checkRuntimeFiltersItem(WhereItem $item, array $allowedFilterList): void
{
$type = $item->getType();
if ($type === self::WHERE_TYPE_AND || $type === self::WHERE_TYPE_OR) {
foreach ($item->getItemList() as $subItem) {
$this->checkRuntimeFiltersItem($subItem, $allowedFilterList);
}
return;
}
$attribute = $item->getAttribute();
if (!$attribute) {
throw new Forbidden("Not allowed runtime filter item.");
}
if ($attribute === 'id') {
return;
}
if (str_contains($attribute, ':')) {
throw new Forbidden("Expressions are not allowed in runtime filter.");
}
if (!str_contains($attribute, '.')) {
return;
}
$isAllowed = in_array($attribute, $allowedFilterList);
if (!$isAllowed && str_ends_with($attribute, 'Id')) {
$isAllowed = in_array(substr($attribute, 0, -2), $allowedFilterList);
}
if (!$isAllowed) {
throw new Forbidden("Not allowed runtime filter $attribute.");
}
}
/**
* @return mixed
* @throws Forbidden
* @throws FormulaError
*/
private function runFormula(string $script)
{
$this->formulaChecker->check($script);
$script = $this->formulaChecker->sanitize($script);
return $this->formulaManager->run($script);
}
}