entityManager->getEntityById(Report::ENTITY_TYPE, $id); if (!$report) { throw new NotFound("Report $id not found."); } $this->reportHelper->checkReportCanBeRun($report); if (!$user) { return $report; } $aclManager = $this->userAclManagerProvider->get($user); if (!$aclManager->checkEntity($user, $report)) { throw new Forbidden("No access to report $id for user {$user->getId()}."); } $entityType = $report->getTargetEntityType(); if ( !$aclManager->checkScope($user, $entityType, AclTable::ACTION_READ) && !$user->isPortal() // @todo Revise. ) { throw new Forbidden("No 'read' access to $entityType."); } return $report; } /** * Run a list report. Access control is applied if a user is passed. * * @throws Error * @throws Forbidden * @throws NotFound * @throws BadRequest */ public function runList( string $id, ?SearchParams $searchParams = null, ?User $user = null, ?ListRunParams $runParams = null, ): ListResult { $runParams = $runParams ?? ListRunParams::create(); $report = $this->fetchReportForRun($id, $user); return $this->reportRunList( report: $report, searchParams: $searchParams, user: $user, runParams: $runParams, ); } /** * Run a sub-report list. Access control is applied if a user is passed. * * @throws Error * @throws Forbidden * @throws NotFound * @throws BadRequest */ public function runSubReportList( string $id, SearchParams $searchParams, SubReportParams $subReportParams, ?User $user = null, ?ListRunParams $runParams = null, ): ListResult { $report = $this->fetchReportForRun($id, $user); if ($report->isInternal()) { $impl = $this->reportHelper->createInternalReport($report); if (!$impl instanceof GridReport) { throw new Error("Bad report class."); } return $impl->runSubReport($searchParams, $subReportParams, $user); } if (!in_array($report->getType(), [Report::TYPE_GRID, Report::TYPE_JOINT_GRID])) { throw new Error("Can't run sub-report for non-Grid report."); } if (!$report->getTargetEntityType()) { throw new Error("No entity type in report $id."); } if ( $searchParams->getWhere() && (!$runParams || !$runParams->skipRuntimeFiltersCheck()) ) { $this->reportHelper->checkRuntimeFilters($searchParams->getWhere(), $report); } return $this->executeSubReportList( data: $this->reportHelper->fetchGridDataFromReport($report), searchParams: $searchParams, subReportParams: $subReportParams, runParams: $runParams, user: $user, ); } /** * Run a grid or joint-grid report. Access control is applied if a user is passed. * * @param ?array $idWhereMap * @throws Error * @throws Forbidden * @throws NotFound * @throws BadRequest */ public function runGrid( string $id, ?WhereItem $whereItem = null, ?User $user = null, ?GridRunParams $runParams = null, ?array $idWhereMap = null, ): GridResult { return $this->runGridOrJoint( id: $id, whereItem: $whereItem, user: $user, runParams: $runParams, idWhereMap: $idWhereMap, ); } /** * Access control is applied if a user is passed. * * @param ?array $idWhereMap * @throws Error * @throws Forbidden * @throws NotFound * @throws BadRequest */ private function runGridOrJoint( string $id, ?WhereItem $whereItem = null, ?User $user = null, ?GridRunParams $runParams = null, ?array $idWhereMap = null, ): GridResult { $report = $this->fetchReportForRun($id, $user); return $this->reportRunGridOrJoint( report: $report, whereItem: $whereItem, user: $user, runParams: $runParams, idWhereMap: $idWhereMap, ); } private function getForeignFieldType(string $entityType, string $link, string $field): ?string { $defs = $this->entityManager->getMetadata()->get($entityType); if (!empty($defs['relations']) && !empty($defs['relations'][$link])) { $foreignScope = $defs['relations'][$link]['entity']; return $this->metadata->get(['entityDefs', $foreignScope, 'fields', $field, 'type']); } return null; } private function getForeignAttributeType(string $entityType, string $link, string $attribute): ?string { $metadata = $this->entityManager->getMetadata(); $defs = $metadata->get($entityType); if (empty($defs['relations']) || empty($defs['relations'][$link])) { return null; } $foreignEntityType = $defs['relations'][$link]['entity'] ?? null; if (!$foreignEntityType) { return null; } return $metadata->get($foreignEntityType, ['attributes', $attribute, 'type']) ?? $metadata->get($foreignEntityType, ['fields', $attribute, 'type']); } /** * @throws Forbidden * @throws Error * @throws BadRequest */ public function prepareSelectBuilder(Report $report, ?User $user = null): SelectBuilder { $data = $this->reportHelper->fetchListDataFromReport($report); return $this->listQueryPreparator->prepare($data, null, $user); } /** * @throws Forbidden * @throws Error * @throws BadRequest */ private function executeListReport( ListData $data, ?SearchParams $searchParams = null, ?ListRunParams $runParams = null, ?User $user = null ): ListResult { $entityType = $data->getEntityType(); $searchParams = $searchParams ?? SearchParams::create(); $runParams = $runParams ?? ListRunParams::create(); if ($runParams->getCustomColumnList()) { $initialColumnList = $data->getColumns(); $newColumnList = []; foreach ($runParams->getCustomColumnList() as $item) { if (str_contains($item, '.')) { if (!in_array($item, $initialColumnList)) { break; } } $newColumnList[] = $item; } $data = $data->withColumns($newColumnList); } if (!$searchParams->getOrderBy()) { if ($data->getOrderBy()) { [$order, $orderBy] = explode(':', $data->getOrderBy()); } else { $orderBy = $this->metadata->get(['entityDefs', $entityType, 'collection', 'orderBy']); $order = $this->metadata->get(['entityDefs', $entityType, 'collection', 'order']); } if ($order) { $order = strtoupper($order); } /** @var 'ASC'|'DESC'|null $order */ if ($orderBy) { $searchParams = $searchParams ->withOrderBy($orderBy) ->withOrder($order); } } $queryBuilder = $this->listQueryPreparator->prepare($data, $searchParams, $user); if ($runParams->isFullSelect()) { $queryBuilder->select(['*']); } $additionalAttributeDefs = []; $linkMultipleFieldList = []; $foreignLinkFieldDataList = []; foreach ($data->getColumns() as $column) { if (!str_contains($column, '.')) { $fieldType = $this->metadata->get(['entityDefs', $entityType, 'fields', $column, 'type']); if (in_array($fieldType, ['linkMultiple', 'attachmentMultiple'])) { $linkMultipleFieldList[] = $column; } continue; } $arr = explode('.', $column); $link = $arr[0]; $attribute = $arr[1]; $foreignAttributeType = $this->getForeignAttributeType($entityType, $link, $attribute); $foreignAttribute = $link . '_' . $attribute; $foreignType = $this->getForeignFieldType($entityType, $link, $attribute); if (in_array($foreignType, ['image', 'file', 'link'])) { $additionalAttributeDefs[$foreignAttribute . 'Id'] = [ 'type' => 'foreign', ]; if ($foreignType === 'link') { $additionalAttributeDefs[$foreignAttribute . 'Name'] = [ 'type' => 'varchar', ]; $foreignEntityType = $this->getForeignLinkForeignEntityType($entityType, $link, $attribute); if ($foreignEntityType) { $foreignLinkFieldDataList[] = (object) [ 'name' => $foreignAttribute, 'entityType' => $foreignEntityType, ]; } } } else { $additionalAttributeDefs[$foreignAttribute] = [ 'type' => $foreignAttributeType, 'relation' => $link, 'foreign' => $attribute, ]; } } $query = $queryBuilder->build(); try { $sth = $this->entityManager->getQueryExecutor()->execute($query); } catch (PDOException $e) { $this->handlePDOException($e); } catch (Exception $e) { $this->handleExecuteQueryException($e); } $count = $this->entityManager ->getRDBRepository($entityType) ->clone($query) ->count(); $collection = $this->injectableFactory->createWith(SthCollection::class, [ 'sth' => $sth, 'entityType' => $entityType, 'attributeDefs' => $additionalAttributeDefs, 'linkMultipleFieldList' => $linkMultipleFieldList, 'foreignLinkFieldDataList' => $foreignLinkFieldDataList, ]); if (!$runParams->returnSthCollection()) { $newCollection = $this->entityManager->getCollectionFactory()->create($entityType); foreach ($collection as $entity) { $newCollection[] = $entity; } $collection = $newCollection; } return new ListResult( collection: $collection, total: $count, columns: $data->getColumns(), columnsData: $data->getColumnsData(), ); } private function getForeignLinkForeignEntityType(string $entityType, string $link, string $field): ?string { $foreignEntityType1 = $this->metadata->get(['entityDefs', $entityType, 'links', $link, 'entity']); return $this->metadata->get(['entityDefs', $foreignEntityType1, 'links', $field, 'entity']); } /** * @throws Forbidden * @throws BadRequest */ private function executeSubReportList( GridData $data, SearchParams $searchParams, SubReportParams $subReportParams, ?ListRunParams $runParams = null, ?User $user = null ): ListResult { $entityType = $data->getEntityType(); $queryBuilder = $this->subReportQueryPreparator->prepare( $data, $searchParams, $subReportParams, $user ); if ($runParams && $runParams->isFullSelect()) { $queryBuilder->select(['*']); } $collection = $this->entityManager ->getRDBRepository($entityType) ->clone($queryBuilder->build()) ->find(); $count = $this->entityManager ->getRDBRepository($entityType) ->clone($queryBuilder->build()) ->count(); $service = $this->recordServiceContainer->get($entityType); $loaderParams = LoaderParams::create()->withSelect($searchParams->getSelect()); foreach ($collection as $entity) { $this->listLoadProcessor->process($entity, $loaderParams); $service->prepareEntityForOutput($entity); } return new ListResult($collection, $count); } /** * @throws Error * @throws Forbidden * @throws BadRequest */ public function executeGridReport( GridData $data, ?WhereItem $where, ?User $user = null, ): GridResult { $groupValueMap = []; $numericColumnList = []; $subListColumnList = []; $summaryColumnList = []; foreach ($data->getColumns() as $item) { if ($this->gridHelper->isColumnNumeric($item, $data)) { $numericColumnList[] = $item; } } foreach ($data->getColumns() as $item) { if ($this->gridHelper->isColumnSummary($item, $data)) { $summaryColumnList[] = $item; continue; } if ($this->gridHelper->isColumnEligibleForSubList($item, $data)) { $subListColumnList[] = $item; } } if (count($data->getGroupBy()) === 2) { $subListColumnList = []; } $columnToBuildList = count($data->getGroupBy()) === 2 ? $summaryColumnList : $data->getColumns(); $columnToBuildList = array_values(array_filter( $columnToBuildList, fn (string $item) => !in_array($item, $subListColumnList) )); $aggregatedColumnList = array_values(array_filter( $data->getColumns(), fn (string $item) => !in_array($item, $subListColumnList) )); $data = $data->withAggregatedColumns($aggregatedColumnList); if ($aggregatedColumnList === [] && $data->getGroupBy() === []) { $data = $data->withAggregatedColumns(['COUNT:(id)']); } if (count($subListColumnList)) { foreach ($columnToBuildList as $column) { if ($this->gridHelper->isColumnSubListAggregated($column)) { $subListColumnList[] = $column; } } } $this->gridHelper->checkColumnsAvailability($data->getEntityType(), $data->getGroupBy()); $this->gridHelper->checkColumnsAvailability($data->getEntityType(), $aggregatedColumnList); $query = $this->gridQueryPreparator->prepare($data, $where, $user); if ($query->getHaving() && !$query->getGroup()) { $this->throwError('badParams', 'havingFilterWithoutGroupByError'); } try { $sth = $this->entityManager ->getQueryExecutor() ->execute($query); } catch (PDOException $e) { $this->handlePDOException($e); } catch (Exception $e) { $this->handleExecuteQueryException($e); } $rows = $sth->fetchAll(PDO::FETCH_ASSOC); $linkColumnList = array_merge( $this->gridHelper->obtainLinkColumnList($data), $this->gridHelper->obtainLinkColumnListFromColumns($data, $aggregatedColumnList), ); $grouping = []; $sums = []; $cellValueMaps = (object) []; $nonSummaryColumnGroupMap = (object) []; $columnTypeMap = []; $columnDecimalPlacesMap = []; $columnNameMap = []; $nonSummaryColumnList = array_values(array_diff($data->getColumns(), $summaryColumnList)); $emptyStringGroupExcluded = false; $groupList = array_map( fn (Expression $expr): string => $expr->getValue(), $query->getGroup() ); $this->gridResultHelper->fixRows($rows, $groupList, $emptyStringGroupExcluded); $this->gridResultHelper->populateGroupValueMap($data, $groupList, $rows, $groupValueMap); $this->gridResultHelper->populateGrouping($data, $groupList, $rows, $where, $grouping); $this->gridResultHelper->populateRows($data, $groupList, $grouping, $rows, $nonSummaryColumnList); $this->gridResultHelper->populateGroupValueMapByLinkColumns($data, $linkColumnList, $rows, $groupValueMap); $this->gridResultHelper->populateGroupValueMapForDateFunctions($data, $grouping, $groupValueMap); $this->gridResultHelper->populateColumnInfo($data, $columnTypeMap, $columnDecimalPlacesMap, $columnNameMap); $this->gridResultHelper->sortGrouping($data, $grouping, $groupValueMap); $reportData = $this->gridBuilder->build( data: $data, rows: $rows, groupList: $groupList, columns: $columnToBuildList, sums: $sums, cellValueMaps: $cellValueMaps, ); $nonSummaryData = $this->gridBuilder->buildNonSummary( columnList: $data->getColumns(), summaryColumnList: $summaryColumnList, data: $data, rows: $rows, groupList: $groupList, cellValueMaps: $cellValueMaps, nonSummaryColumnGroupMap: $nonSummaryColumnGroupMap, ); $subListData = $this->executeGridReportSubList( groupValueList: $grouping[0], columnList: $subListColumnList, data: $data, where: $where, user: $user, ); $resultObject = new GridResult( entityType: $data->getEntityType(), groupByList: $data->getGroupBy(), columnList: $data->getColumns(), numericColumnList: $numericColumnList, summaryColumnList: $summaryColumnList, nonSummaryColumnList: $nonSummaryColumnList, subListColumnList: $subListColumnList, aggregatedColumnList: $aggregatedColumnList, nonSummaryColumnGroupMap: $nonSummaryColumnGroupMap, // stdClass subListData: $subListData, // object sums: (object) $sums, // object groupValueMap: $groupValueMap, // array> columnNameMap: $columnNameMap, // array columnTypeMap: $columnTypeMap, // array cellValueMaps: $cellValueMaps, // object (when grouping by link) grouping: $grouping, // array{string[]}|array{string[], string[]} reportData: $reportData, // object|object> nonSummaryData: $nonSummaryData, // object> chartType: $data->getChartType(), chartDataList: $data->getChartDataList(), // stdClass[] columnDecimalPlacesMap: (object) $columnDecimalPlacesMap, // object, emptyStringGroupExcluded: $emptyStringGroupExcluded, ); $resultObject->setSuccess($data->getSuccess()); if ($data->getChartColors()) { $resultObject->setChartColors((object) $data->getChartColors()); } if ($data->getChartColor() && $data->getChartType()) { $resultObject->setChartColor($data->getChartColor()); } $this->gridResultHelper->calculateSums($data, $resultObject); return $resultObject; } /** * @return never-return * @throws Error */ private function throwError(string $reason, string $message): void { // As of v7.1. if (class_exists("Espo\\Core\\Exceptions\\Error\\Body")) { throw Error::createWithBody( $reason, Error\Body::create() ->withMessageTranslation($message, 'Report') ->encode() ); } throw new Error($message); } /** * @param string[] $groupValueList * @param string[] $columnList * @return stdClass // object * * @throws Forbidden * @throws BadRequest */ private function executeGridReportSubList( array $groupValueList, array $columnList, GridData $data, ?WhereItem $where, ?User $user = null, ): object { if ($columnList === []) { return (object) []; } $result = (object) []; foreach ($groupValueList as $groupValue) { $result->$groupValue = $this->executeGridReportSubListItem( groupValue: $groupValue, columnList: $columnList, data: $data, where: $where, user: $user, ); } return $result; } /** * @param ?scalar $groupValue * @param string[] $columnList * @return stdClass[] * * @throws Forbidden * @throws BadRequest * * @todo Add complex expression support. E.g. `LOWER:(name)`. */ private function executeGridReportSubListItem( $groupValue, array $columnList, GridData $data, ?WhereItem $where, ?User $user = null, ): array { if ($groupValue === '') { $groupValue = null; } $realColumnList = array_map( function (string $column): string { return !str_contains($column, ':') ? $column : explode(':', $column)[1]; }, $columnList ); $realColumnList = array_filter($realColumnList, function ($it) { if (str_starts_with($it, '(')) { return false; } return true; }); $realColumnList = array_values($realColumnList); $query = $this->subListQueryPreparator->prepare( data: $data, groupValue: $groupValue, columnList: $columnList, realColumnList: $realColumnList, where: $where, user: $user, ); $linkColumnList = $this->gridHelper->obtainLinkColumnListFromColumns($data, $realColumnList); $columnAttributeMap = []; foreach ($columnList as $column) { if (in_array($column, $linkColumnList)) { $columnAttributeMap[$column] = $column . 'Name'; continue; } if (str_contains($column, ':')) { $columnAttributeMap[$column] = explode(':', $column)[1]; continue; } $columnAttributeMap[$column] = $column; } $limit = $this->config->get('reportGridSubListLimit') ?? self::GRID_SUB_LIST_LIMIT; $collection = $this->entityManager ->getRDBRepository($data->getEntityType()) ->clone($query) ->limit(0, $limit) ->find(); $itemList = []; foreach ($collection as $entity) { $item = (object) ['id' => $entity->getId()]; foreach ($columnList as $column) { $attribute = $columnAttributeMap[$column]; $columnData = $this->gridHelper->getDataFromColumnName($data->getEntityType(), $column); $item->$column = $this->getCellDisplayValueFromEntity($entity, $attribute, $columnData); } $itemList[] = $item; } return $itemList; } /** * @return scalar|string[] */ private function getCellDisplayValueFromEntity(Entity $entity, string $attribute, ColumnData $columnData) { if ($columnData->fieldType === 'datetimeOptional' && $entity->get($attribute . 'Date')) { $attribute = $attribute . 'Date'; $columnData->fieldType = 'date'; } return $this->gridUtil->getCellDisplayValue($entity->get($attribute), $columnData); } /** * @return array> * @throws Forbidden * @throws NotFound * @throws Error * @throws BadRequest */ public function getReportResultsTableData( string $id, ?WhereItem $where = null, ?string $column = null, ?User $user = null ): array { $report = $this->entityManager->getRDBRepositoryByClass(Report::class)->getById($id); if (!$report) { throw new NotFound(); } if ($report->getType() === Report::TYPE_LIST) { $searchParams = SearchParams::create(); if ($where) { $searchParams = $searchParams->withWhere($where); } $result = $this->runList($id, $searchParams, $user); } else { $result = $this->runGrid($id, $where, $user); } if ($result instanceof ListResult) { /** @var array $resultData */ $resultData = []; foreach ($result->getCollection() as $e) { $resultData[] = get_object_vars($e->getValueMap()); } } else { $resultData = $result; } $data = (object) [ 'userId' => $user ? $user->getId() : $this->user->getId(), 'specificColumn' => $column, ]; $service = $this->injectableFactory->create(SendingService::class); /** @var GridResult|array $resultData */ $service->buildData($data, $resultData, $report); return $data->tableData ?? []; } /** * @return never-return * @throws Error */ private function handlePDOException(PDOException $e): void { if ((int)$e->getCode() === 42000) { $message = str_contains($e->getMessage(), ': 1055') ? 'onlyFullGroupByError' : 'sqlSyntaxError'; $this->log->error($e->getMessage()); $this->throwError('sqlSyntaxError', $message); } if ($e->getCode() === '42S22') { $this->log->error($e->getMessage()); $this->throwError('invalidColumnError', 'invalidColumnError'); } $this->log->error($e->getMessage()); $this->throwError('executionError', 'executionError'); } /** * @return never-return * @throws Error */ private function handleExecuteQueryException(Exception $e): void { $msg = $e->getMessage() . "; file: {$e->getFile()}; line: {$e->getLine()}"; $this->log->error($msg); $this->throwError('executionError', 'executionError'); } /** * @throws BadRequest * @throws Error * @throws Forbidden */ public function reportRunList( Report $report, ?SearchParams $searchParams, ?User $user, ?ListRunParams $runParams = null, ): ListResult { $runParams ??= ListRunParams::create(); if ($report->isInternal()) { $impl = $this->reportHelper->createInternalReport($report); if (!$impl instanceof ListReport) { throw new Error("Bad report class."); } return $impl->run($searchParams, $user); } if ($report->getType() !== Report::TYPE_LIST) { throw new Error("Can't run non-List report as List."); } if (!$report->getTargetEntityType()) { $id = $report->getId(); throw new Error("No entity type in report $id."); } if ( $searchParams && $searchParams->getWhere() && !$runParams->skipRuntimeFiltersCheck() ) { $this->reportHelper->checkRuntimeFilters($searchParams->getWhere(), $report); } return $this->executeListReport( data: $this->reportHelper->fetchListDataFromReport($report), searchParams: $searchParams, runParams: $runParams, user: $user, ); } /** * @param ?array $idWhereMap * @throws BadRequest * @throws Error * @throws Forbidden */ public function reportRunGridOrJoint( Report $report, ?WhereItem $whereItem, ?User $user, ?GridRunParams $runParams = null, ?array $idWhereMap = null, ): GridResult { if ($report->isInternal()) { $impl = $this->reportHelper->createInternalReport($report); if (!$impl instanceof GridReport) { throw new Error("Bad report class."); } return $impl->run($whereItem, $user); } if ( $whereItem && (!$runParams || !$runParams->skipRuntimeFiltersCheck()) ) { $this->reportHelper->checkRuntimeFilters($whereItem, $report); } switch ($report->getType()) { case Report::TYPE_GRID: return $this->executeGridReport( $this->reportHelper->fetchGridDataFromReport($report), $whereItem, $user ); case Report::TYPE_JOINT_GRID: return $this->injectableFactory ->createWith(JointGridExecutor::class, ['service' => $this]) ->execute( $this->reportHelper->fetchJointDataFromReport($report), $user, $idWhereMap ); } throw new Error("Unknown type."); } }