src/module-elasticsuite-catalog/Model/ResourceModel/Product/Fulltext/Collection.php
<?php
/**
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade Smile ElasticSuite to newer
* versions in the future.
*
* @category Smile
* @package Smile\ElasticsuiteCatalog
* @author Aurelien FOUCRET <aurelien.foucret@smile.fr>
* @copyright 2020 Smile
* @license Open Software License ("OSL") v. 3.0
*/
namespace Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext;
use Smile\ElasticsuiteCatalog\Model\Search\Request\Field\Mapper as RequestFieldMapper;
use Smile\ElasticsuiteCore\Search\Adapter\Elasticsuite\Response\QueryResponse;
use Smile\ElasticsuiteCore\Search\Request\BucketInterface;
use Smile\ElasticsuiteCore\Search\Request\QueryInterface;
use Smile\ElasticsuiteCore\Search\RequestInterface;
/**
* Search engine product collection.
*
* @category Smile
* @package Smile\ElasticsuiteCatalog
* @author Aurelien FOUCRET <aurelien.foucret@smile.fr>
*
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
* @SuppressWarnings(PHPMD.TooManyPublicMethods)
*/
class Collection extends \Magento\Catalog\Model\ResourceModel\Product\Collection
{
/**
* @var QueryResponse
*/
private $queryResponse;
/**
* @var \Smile\ElasticsuiteCore\Search\Request\Builder
*/
private $requestBuilder;
/**
* @var \Magento\Search\Model\SearchEngine
*/
private $searchEngine;
/**
* @var string|QueryInterface
*/
private $query;
/**
* @var string
*/
private $searchRequestName;
/**
* @var array
*/
private $filters = [];
/**
* @var QueryInterface[]
*/
private $queryFilters = [];
/**
* @var array
*/
private $facets = [];
/**
* @var boolean
*/
private $isSpellchecked = false;
/**
* Pager page size backup variable.
* Page size is always set to false in _renderFiltersBefore() after executing the query to Elasticsearch,
* to be sure to pull correctly all matched products from the DB.
* But it needs to be reset so low-level methods like getLastPageNumber() still work.
*
* @var integer|false
*/
private $originalPageSize = false;
/**
* @var array
*/
private $countByAttributeSet;
/**
* @var array
*/
private $countByAttributeCode;
/**
* @var RequestFieldMapper
*/
private $requestFieldMapper;
/**
* Constructor.
*
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
*
* @param \Magento\Framework\Data\Collection\EntityFactory $entityFactory Collection entity factory
* @param \Psr\Log\LoggerInterface $logger Logger.
* @param \Magento\Framework\Data\Collection\Db\FetchStrategyInterface $fetchStrategy Db Fetch strategy.
* @param \Magento\Framework\Event\ManagerInterface $eventManager Event manager.
* @param \Magento\Eav\Model\Config $eavConfig EAV configuration.
* @param \Magento\Framework\App\ResourceConnection $resource DB connection.
* @param \Magento\Eav\Model\EntityFactory $eavEntityFactory Entity factory.
* @param \Magento\Catalog\Model\ResourceModel\Helper $resourceHelper Resource helper.
* @param \Magento\Framework\Validator\UniversalFactory $universalFactory Standard factory.
* @param \Magento\Store\Model\StoreManagerInterface $storeManager Store manager.
* @param \Magento\Framework\Module\Manager $moduleManager Module manager.
* @param \Magento\Catalog\Model\Indexer\Product\Flat\State $catalogProductFlatState Flat index state.
* @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig Store configuration.
* @param \Magento\Catalog\Model\Product\OptionFactory $productOptionFactory Product options factory.
* @param \Magento\Catalog\Model\ResourceModel\Url $catalogUrl Catalog URL resource model.
* @param \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate Timezone helper.
* @param \Magento\Customer\Model\Session $customerSession Customer session.
* @param \Magento\Framework\Stdlib\DateTime $dateTime Datetime helper.
* @param \Magento\Customer\Api\GroupManagementInterface $groupManagement Customer group manager.
* @param \Smile\ElasticsuiteCore\Search\Request\Builder $requestBuilder Search request builder.
* @param \Magento\Search\Model\SearchEngine $searchEngine Search engine
* @param RequestFieldMapper $requestFieldMapper Search request field mapper.
* @param \Magento\Framework\DB\Adapter\AdapterInterface $connection Db Connection.
* @param string $searchRequestName Search request name.
*/
public function __construct(
\Magento\Framework\Data\Collection\EntityFactory $entityFactory,
\Psr\Log\LoggerInterface $logger,
\Magento\Framework\Data\Collection\Db\FetchStrategyInterface $fetchStrategy,
\Magento\Framework\Event\ManagerInterface $eventManager,
\Magento\Eav\Model\Config $eavConfig,
\Magento\Framework\App\ResourceConnection $resource,
\Magento\Eav\Model\EntityFactory $eavEntityFactory,
\Magento\Catalog\Model\ResourceModel\Helper $resourceHelper,
\Magento\Framework\Validator\UniversalFactory $universalFactory,
\Magento\Store\Model\StoreManagerInterface $storeManager,
\Magento\Framework\Module\Manager $moduleManager,
\Magento\Catalog\Model\Indexer\Product\Flat\State $catalogProductFlatState,
\Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
\Magento\Catalog\Model\Product\OptionFactory $productOptionFactory,
\Magento\Catalog\Model\ResourceModel\Url $catalogUrl,
\Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate,
\Magento\Customer\Model\Session $customerSession,
\Magento\Framework\Stdlib\DateTime $dateTime,
\Magento\Customer\Api\GroupManagementInterface $groupManagement,
\Smile\ElasticsuiteCore\Search\Request\Builder $requestBuilder,
\Magento\Search\Model\SearchEngine $searchEngine,
RequestFieldMapper $requestFieldMapper,
\Magento\Framework\DB\Adapter\AdapterInterface $connection = null,
$searchRequestName = 'catalog_view_container'
) {
parent::__construct(
$entityFactory,
$logger,
$fetchStrategy,
$eventManager,
$eavConfig,
$resource,
$eavEntityFactory,
$resourceHelper,
$universalFactory,
$storeManager,
$moduleManager,
$catalogProductFlatState,
$scopeConfig,
$productOptionFactory,
$catalogUrl,
$localeDate,
$customerSession,
$dateTime,
$groupManagement,
$connection
);
$this->requestBuilder = $requestBuilder;
$this->searchEngine = $searchEngine;
$this->requestFieldMapper = $requestFieldMapper;
$this->searchRequestName = $searchRequestName;
}
/**
* {@inheritDoc}
*/
public function getSize()
{
if ($this->_totalRecords === null) {
$this->loadProductCounts();
}
return $this->_totalRecords;
}
/**
* {@inheritDoc}
*/
public function clear()
{
$this->_isFiltersRendered = false;
return parent::clear();
}
/**
* {@inheritDoc}
*/
public function setOrder($attribute, $dir = self::SORT_ORDER_DESC)
{
if (!isset($this->_orders[$attribute]) || ($this->_orders[$attribute] !== $dir)) {
$this->_orders[$attribute] = $dir;
// Reset Filter Rendering, because otherwise the new ordering will not be picked up by ::_renderFiltersBefore.
$this->_isFiltersRendered = false;
}
return $this;
}
/**
* Reset the sort order.
*
* @return self
*/
public function resetOrder()
{
$this->_orders = [];
return $this;
}
/**
* {@inheritDoc}
*/
public function setCurPage($page)
{
$this->_isFiltersRendered = false;
return parent::setCurPage($page);
}
/**
* {@inheritDoc}
*/
public function setPageSize($size)
{
/*
* Explicitely setting the page size to false or null is to be treated as having not set any page size.
* That is: no pagination, all items are expected.
*/
$size = ($size === null) ? false : $size;
$this->_pageSize = $size;
$this->_isFiltersRendered = false;
return $this;
}
/**
* {@inheritDoc}
*/
public function addFieldToFilter($field, $condition = null)
{
$field = $this->mapFieldName($field);
$this->filters[$field] = $condition;
$this->_isFiltersRendered = false;
return $this;
}
/**
* {@inheritDoc}
*/
public function addAttributeToSort($attribute, $dir = self::SORT_ORDER_ASC)
{
if ($attribute !== 'entity_id') {
return $this->setOrder($attribute, $dir);
}
return $this;
}
/**
* Append a prebuilt (QueryInterface) query filter to the collection.
*
* @param QueryInterface $queryFilter Query filter.
*
* @return \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\Collection
*/
public function addQueryFilter(QueryInterface $queryFilter)
{
$this->queryFilters[] = $queryFilter;
$this->_isFiltersRendered = false;
return $this;
}
/**
* Remove a specific field filter.
*
* @param string $field Field to remove the filter for.
*
* @return \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\Collection
*/
public function removeFieldFilter($field)
{
$field = $this->mapFieldName($field);
unset($this->filters[$field]);
$this->_isFiltersRendered = false;
return $this;
}
/**
* Remove all field filters.
*
* @return \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\Collection
*/
public function removeFieldFilters()
{
$this->filters = [];
$this->_isFiltersRendered = false;
return $this;
}
/**
* Remove all previously added query filters.
*
* @return \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\Collection
*/
public function removeQueryFilters()
{
$this->queryFilters = [];
$this->_isFiltersRendered = false;
return $this;
}
/**
* Set search query filter in the collection.
*
* @param string|QueryInterface $query Search query text.
*
* @return \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\Collection
*/
public function setSearchQuery($query)
{
$this->query = $query;
$this->_isFiltersRendered = false;
return $this;
}
/**
* Add search query filter.
*
* @deprecated Replaced by setSearchQuery
*
* @param string $query Search query text.
*
* @return \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\Collection
*/
public function addSearchFilter($query)
{
return $this->setSearchQuery($query);
}
/**
* Return field faceted data from faceted search result.
*
* @param string $field Facet field.
*
* @return array
*/
public function getFacetedData($field)
{
$this->_renderFilters();
$result = [];
$aggregations = $this->queryResponse->getAggregations();
$bucket = $aggregations->getBucket($field);
if ($bucket) {
foreach ($bucket->getValues() as $value) {
$metrics = $value->getMetrics();
$result[$value->getValue()] = $metrics;
}
}
return $result;
}
/**
* {@inheritDoc}
*/
public function addCategoryFilter(\Magento\Catalog\Model\Category $category)
{
$categoryId = $category->getId();
if ($categoryId) {
$this->addFieldToFilter('category_ids', $categoryId);
$this->_productLimitationFilters['category_ids'] = $categoryId;
}
$this->_isFiltersRendered = false;
return $this;
}
/**
* {@inheritDoc}
*/
public function setVisibility($visibility)
{
$this->addFieldToFilter('visibility', $visibility);
return $this;
}
/**
* Indicates if the collection is spellchecked or not.
*
* @return boolean
*/
public function isSpellchecked()
{
return $this->isSpellchecked;
}
/**
* Filter in stock product.
*
* @return \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\Collection
*/
public function addIsInStockFilter()
{
$this->addFieldToFilter('stock.is_in_stock', true);
return $this;
}
/**
* Set param for a sort order.
*
* @param string $sortName Sort order name (eg. position, ...).
* @param string $sortField Sort field.
* @param string $nestedPath Optional nested path for the sort field.
* @param array $nestedFilter Optional nested filter for the sort field.
*
* @return \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\Collection
*/
public function addSortFilterParameters($sortName, $sortField, $nestedPath = null, $nestedFilter = null)
{
$sortParams = [];
if (isset($this->_productLimitationFilters['sortParams'])) {
$sortParams = $this->_productLimitationFilters['sortParams'];
}
$sortParams[$sortName] = [
'sortField' => $sortField,
'nestedPath' => $nestedPath,
'nestedFilter' => $nestedFilter,
];
$this->_productLimitationFilters['sortParams'] = $sortParams;
return $this;
}
/**
* Get actual page size if is defined or return all results.
*
* @return integer|false
*/
public function getPageSize()
{
if ($this->_pageSize !== false) {
return $this->_pageSize;
}
if ($this->originalPageSize !== false) {
return $this->originalPageSize;
}
return $this->getSize();
}
/**
* Retrieve collection last page number.
*
* @return int
* @SuppressWarnings(PHPMD.ElseExpression)
*/
public function getLastPageNumber()
{
$collectionSize = (int) $this->getSize();
if (0 === $collectionSize) {
return 1;
} elseif ($this->_pageSize) {
return (int) ceil($collectionSize / $this->_pageSize);
} elseif ($this->originalPageSize) {
return (int) ceil($collectionSize / $this->originalPageSize);
} else {
return 1;
}
}
/**
* @SuppressWarnings(PHPMD.CamelCaseMethodName)
*
* {@inheritdoc}
*/
protected function _renderFiltersBefore()
{
$searchRequest = $this->prepareRequest();
$this->queryResponse = $this->searchEngine->search($searchRequest);
// Filter search results. The pagination has to be resetted since it is managed by the engine itself.
$docIds = array_map(
function (\Magento\Framework\Api\Search\Document $doc) {
return (int) $doc->getId();
},
$this->queryResponse->getIterator()->getArrayCopy()
);
if (empty($docIds)) {
$docIds[] = 0;
}
$this->getSelect()->where('e.entity_id IN (?)', ['in' => $docIds]);
$orderList = join(',', $docIds);
$this->getSelect()->reset(\Magento\Framework\DB\Select::ORDER);
$this->getSelect()->order(new \Zend_Db_Expr("FIELD(e.entity_id,$orderList)"));
$this->originalPageSize = $this->getPageSize();
$this->isSpellchecked = $searchRequest->isSpellchecked();
parent::_renderFiltersBefore();
}
/**
* Set _pageSize false since it is managed by the engine and might have been changed since _renderFiltersBefore.
*
* @SuppressWarnings(PHPMD.CamelCaseMethodName)
*
* {@inheritDoc}
*/
protected function _beforeLoad()
{
if ($this->_pageSize !== false) {
$this->originalPageSize = $this->_pageSize;
$this->_pageSize = false;
}
return parent::_beforeLoad();
}
/**
* @SuppressWarnings(PHPMD.CamelCaseMethodName)
*
* {@inheritDoc}
*/
protected function _renderFilters()
{
$this->_filters = [];
return parent::_renderFilters();
}
/**
* @SuppressWarnings(PHPMD.CamelCaseMethodName)
*
* {@inheritDoc}
*/
protected function _renderOrders()
{
// Sort orders are managed through the search engine and are added through the prepareRequest method.
return $this;
}
/**
* @SuppressWarnings(PHPMD.CamelCaseMethodName)
*
* {@inheritDoc}
*/
protected function _afterLoad()
{
// Resort items according the search response.
$originalItems = $this->_items;
$this->_items = [];
foreach ($this->queryResponse->getIterator() as $document) {
$documentId = $document->getId();
if (isset($originalItems[$documentId])) {
$originalItems[$documentId]->setDocumentScore($document->getScore());
$originalItems[$documentId]->setDocumentSource($document->getSource());
$this->_items[$documentId] = $originalItems[$documentId];
}
}
if (false === $this->_pageSize && false !== $this->originalPageSize) {
$this->_pageSize = $this->originalPageSize;
}
return parent::_afterLoad();
}
/**
* Load product count :
* - collection size
* - number of products by attribute set (legacy)
* - number of products by attribute code
*
* @return void
*/
private function loadProductCounts(): void
{
$storeId = $this->getStoreId();
$requestName = $this->searchRequestName;
$facets = [
['name' => 'attribute_set_id', 'type' => BucketInterface::TYPE_TERM, 'size' => 0],
['name' => 'indexed_attributes', 'type' => BucketInterface::TYPE_TERM, 'size' => 0],
];
$searchRequest = $this->requestBuilder->create(
$storeId,
$requestName,
0,
0,
$this->query,
[],
$this->filters,
$this->queryFilters,
$facets,
true
);
$searchResponse = $this->searchEngine->search($searchRequest);
$this->_totalRecords = $searchResponse->count();
$this->countByAttributeSet = [];
$this->countByAttributeCode = [];
$this->isSpellchecked = $searchRequest->isSpellchecked();
$attributeSetIdBucket = $searchResponse->getAggregations()->getBucket('attribute_set_id');
$attributeCodeBucket = $searchResponse->getAggregations()->getBucket('indexed_attributes');
if ($attributeSetIdBucket) {
foreach ($attributeSetIdBucket->getValues() as $value) {
$metrics = $value->getMetrics();
$this->countByAttributeSet[$value->getValue()] = $metrics['count'];
}
}
if ($attributeCodeBucket) {
foreach ($attributeCodeBucket->getValues() as $value) {
$metrics = $value->getMetrics();
$this->countByAttributeCode[$value->getValue()] = $metrics['count'];
}
}
}
/**
* Prepare the search request before it will be executed.
*
* @return RequestInterface
*/
private function prepareRequest()
{
// Store id and request name.
$storeId = $this->getStoreId();
$searchRequestName = $this->searchRequestName;
// Pagination params.
$size = $this->getPageSize();
$from = $size * (max(1, $this->getCurPage()) - 1);
// Setup sort orders.
$sortOrders = $this->prepareSortOrders();
$searchRequest = $this->requestBuilder->create(
$storeId,
$searchRequestName,
$from,
$size,
$this->query,
$sortOrders,
$this->filters,
$this->queryFilters,
$this->facets,
false
);
return $searchRequest;
}
/**
* Prepare sort orders for the request builder.
*
* @return array()
*/
private function prepareSortOrders()
{
$sortOrders = [];
$useProductLimitation = isset($this->_productLimitationFilters['sortParams']);
foreach ($this->_orders as $attribute => $direction) {
$sortParams = ['direction' => $direction];
$sortField = $this->mapFieldName($attribute);
if ($useProductLimitation && isset($this->_productLimitationFilters['sortParams'][$attribute])) {
$sortField = $this->_productLimitationFilters['sortParams'][$attribute]['sortField'];
$sortParams = array_merge($sortParams, $this->_productLimitationFilters['sortParams'][$attribute]);
} elseif ($attribute == 'price') {
// Change the price sort field to the nested price field.
$sortField = 'price.price';
$sortParams['nestedPath'] = 'price';
// Ensure we sort on the position field of the current customer group.
$customerGroupId = $this->_productLimitationFilters['customer_group_id'];
$sortParams['nestedFilter'] = ['price.customer_group_id' => $customerGroupId];
}
$sortOrders[$sortField] = $sortParams;
}
return $sortOrders;
}
/**
* Convert standard field name to ES fieldname.
* (eg. category_ids => category.category_id).
*
* @param string $fieldName Field name to be mapped.
*
* @return string
*/
private function mapFieldName($fieldName)
{
return $this->requestFieldMapper->getMappedFieldName($fieldName);
}
}