SuiteCRM-Core/public/legacy/lib/Search/SearchResults.php
2026-04-23 08:23:18 +01:00

386 lines
13 KiB
PHP

<?php
/**
* SugarCRM Community Edition is a customer relationship management program developed by
* SugarCRM, Inc. Copyright (C) 2004-2013 SugarCRM Inc.
*
* SuiteCRM is an extension to SugarCRM Community Edition developed by SalesAgility Ltd.
* Copyright (C) 2011 - 2021 SalesAgility Ltd.
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License version 3 as published by the
* Free Software Foundation with the addition of the following permission added
* to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK
* IN WHICH THE COPYRIGHT IS OWNED BY SUGARCRM, SUGARCRM DISCLAIMS THE WARRANTY
* OF NON INFRINGEMENT OF THIRD PARTY RIGHTS.
*
* 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 or write to the Free
* Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301 USA.
*
* You can contact SugarCRM, Inc. headquarters at 10050 North Wolfe Road,
* SW2-130, Cupertino, CA 95014, USA. or at email address contact@sugarcrm.com.
*
* 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 Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "Powered by
* SugarCRM" logo and "Supercharged by SuiteCRM" logo. If the display of the logos is not
* reasonably feasible for technical reasons, the Appropriate Legal Notices must
* display the words "Powered by SugarCRM" and "Supercharged by SuiteCRM".
*/
namespace SuiteCRM\Search;
use BeanFactory;
use LoggerManager;
use SugarBean;
use SuiteCRM\Exception\Exception;
use SuiteCRM\Exception\InvalidArgumentException;
if (!defined('sugarEntry') || !sugarEntry) {
die('Not A Valid Entry Point');
}
/**
* Class SearchResults holds results and metadata of a search.
*
* @author Vittorio Iocolano
*/
#[\AllowDynamicProperties]
class SearchResults
{
/** @var array Contains the results ids */
private $hits;
/** @var array Contains the scores of each hit. This should match in structure with $hits */
private $scores;
/** @var array Similar to scores, but customisable by the search engine */
private $options;
/** @var float The number of seconds it took to perform the search */
private $searchTime;
/** @var int The total number of hits (without pagination) */
private $total;
/** @var bool Flag specifying if the (nested) hits are grouped by the modules */
private $groupedByModule;
/**
* SearchResults constructor.
*
* @param array $hits Contains the results ids
* @param bool $groupedByModule Flag specifying if the (nested) hits are grouped by the modules
* @param float|null $searchTime The number of seconds it took to perform the search
* @param int|null $total The number of total hits (without pagination)
* @param array|null $scores Contains the scores of each hit. This should match in structure with $hits
* @param array|null $options Similar to scores, but customisable by the search engine
* @throws InvalidArgumentException
*/
public function __construct(
array $hits,
$groupedByModule = true,
?float $searchTime = null,
?int $total = null,
?array $scores = null,
?array $options = null
) {
$this->hits = $hits;
$this->scores = $scores;
$this->options = $options;
$this->groupedByModule = $groupedByModule;
$this->searchTime = $searchTime;
$this->total = $total;
if ($this->scores !== null && count($hits) !== count((array) $scores)) {
throw new InvalidArgumentException('The sizes of $hits and $scores must match.');
}
}
/**
* Returns the results.
*
* Note that the structure of the results depends whether of not they have been nested by module or not.
*
* @return array
*/
public function getHits(): array
{
return $this->hits;
}
/**
* Fetches the results (originally just module->id) as Beans.
*
* @return array
* @throws Exception
* @see getHits()
*/
public function getHitsAsBeans(): array
{
$searchHits = $this->hits;
$parsed = [];
foreach ($searchHits as $module => $beans) {
foreach ((array)$beans['results'] as $bean) {
$obj = BeanFactory::getBean($module, $bean);
// if a search found a bean but suitecrm does not, it could happens
// maybe the bean is deleted but elsasticsearch is not re-indexing yet.
// so at this point we trying to rebuild the index and try again to get bean:
if (!$obj) {
ElasticSearch\ElasticSearchIndexer::repairElasticsearchIndex();
$obj = BeanFactory::getBean($module, $bean);
}
if (!$obj) {
throw new Exception('Error retrieving bean: ' . $module . ' [' . $bean . ']');
}
$obj->load_relationships();
$fieldDefs = $obj->getFieldDefinitions();
$obj = $this->formatForDisplay($obj, $fieldDefs);
$parsed[$module][] = $this->updateFieldDefLinks($obj, $fieldDefs);
}
}
return $parsed;
}
/**
* Format data so it can be correctly displayed on search results
*
* @param SugarBean $obj
* @param array $fieldDefs
* @return SugarBean
*/
protected function formatForDisplay(SugarBean $obj, array $fieldDefs): SugarBean
{
global $app_list_strings, $locale;
foreach ($fieldDefs as $fieldDef) {
$value = $obj->{$fieldDef['name']} ?? null;
if (isset($value)) {
switch ($fieldDef['type']){
case 'enum':
case 'dynamicenum':
if (isset($app_list_strings[$obj->field_name_map[$fieldDef['name']]['options']][$value]) &&
isset($obj->field_name_map[$fieldDef['name']]['options'])
) {
$obj->{$fieldDef['name']} = $app_list_strings[$obj->field_name_map[$fieldDef['name']]['options']][$value];
}
break;
case 'multienum':
$optionsListKey = $obj->field_name_map[$fieldDef['name']]['options'] ?? '';
$availableOptions = $app_list_strings[$optionsListKey] ?? [];
$validOptionValues = array_filter(
array: array_map(
callback: static fn($key) => $availableOptions[$key] ?? null,
array: unencodeMultienum($value)
),
callback: static fn($value) => $value !== null && (string)$value
);
if (!empty($validOptionValues)) {
$obj->{$fieldDef['name']} = implode(', ', $validOptionValues);
}
break;
case 'currency':
if (!str_ends_with($fieldDef['name'], '_usdollar')) {
$params['currency_id'] = getCurrencyId($obj->module_dir, $obj->id);
$params['currency_symbol'] = $locale->currencies[$params['currency_id']]['symbol'] ?? null;
} else {
$params['currency_id'] = $locale->getPrecedentPreference('currency');
$params['currency_symbol'] = $locale->getPrecedentPreference('default_currency_symbol');
$params['convert'] = true;
}
$obj->{$fieldDef['name']} = currency_format_number($value, $params);
break;
}
}
}
return $obj;
}
/**
*
* @param SugarBean $obj
* @param array $fieldDefs
* @return SugarBean
*/
protected function updateFieldDefLinks(SugarBean $obj, array $fieldDefs): SugarBean
{
foreach ($fieldDefs as $fieldDef) {
if (isset($obj->{$fieldDef['name']})) {
$obj = $this->updateObjLinks($obj, $fieldDef);
}
}
return $obj;
}
/**
* Update related links in a bean to show it on results page
*
* @param SugarBean $obj
* @param array $fieldDef
* @return SugarBean
*/
protected function updateObjLinks(SugarBean $obj, array $fieldDef): SugarBean
{
if (isset($fieldDef['link']) && !empty($fieldDef['id_name']) && $fieldDef['type'] === 'relate') {
$relId = $this->getRelatedId($obj, $fieldDef['id_name'], $fieldDef['link']);
if (!empty($relId)) {
$obj->{$fieldDef['name']} = $this->getLink(
$obj->{$fieldDef['name']},
$fieldDef['module'],
$relId,
'DetailView'
);
}
} elseif ($fieldDef['name'] === 'name') {
$obj->{$fieldDef['name']} = $this->getLink(
$obj->{$fieldDef['name']},
$obj->module_name,
$obj->id,
'DetailView'
);
}
return $obj;
}
/**
* resolve related record ID
*
* @param SugarBean $obj
* @param string $idName
* @param string $link
* @return string
*/
protected function getRelatedId(SugarBean $obj, string $idName, string $link): ?string
{
$relField = $idName;
if (isset($obj->$link)) {
$linkedBeans = $obj->$link->getBeans();
if(count($linkedBeans) === 1){
$relId = array_keys($linkedBeans)[0];
} else {
$relId = $obj->$link->getFocus()->$relField;
}
if (is_object($relId)) {
if (method_exists($relId, "getFocus")) {
$relId = $relId->getFocus()->id;
} else {
$relId = null;
}
}
} elseif (isset($obj->$relField)) {
$relId = $obj->$relField;
} else {
$relId = null;
LoggerManager::getLogger()->warn('Unresolved related ID for field: ' . $relField);
}
if (!$relId) {
$relId = '';
}
return $relId;
}
/**
*
* @param string $label
* @param string $module
* @param string $record
* @param string $action
* @return string
* @global array $sugar_config
*/
protected function getLink(string $label, string $module, string $record, string $action): string
{
$link = ajaxLink("index.php?action={$action}&module={$module}&record={$record}");
return "<a href=\"{$link}\"><span>{$label}</span></a>";
}
/**
* Returns an arbitrary scores defining how much related a hit is to the query.
*
* @return mixed[]|null
*/
public function getScores(): ?array
{
return $this->scores;
}
/**
* Returns the total number of hits (without pagination).
*
* @return int|null
*/
public function getTotal(): ?int
{
return $this->total;
}
public function getModuleTotal($module) :?int
{
return $this->getHits()[$module]['totalHits'];
}
/**
* @return mixed[]|null
*/
public function getOptions(): ?array
{
return $this->options;
}
/**
* @param string $key
*
* @return array
*/
public function getOption(string $key): array
{
return $this->options[$key];
}
/**
* Time in seconds it took to perform the search.
*
* @return float|null
*/
public function getSearchTime(): ?float
{
return $this->searchTime;
}
/**
* @return bool
*/
public function isGroupedByModule(): bool
{
return $this->groupedByModule;
}
public function getLargestHitsCount()
{
$largest = 0;
foreach($this->getHits() as $hit)
{
$largest = max($largest, $hit['totalHits']);
}
return $largest;
}
}