Firesphere/silverstripe-solr-search

View on GitHub
src/Results/SearchResult.php

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
<?php
/**
 * class SearchResult|Firesphere\SolrSearch\Results\SearchResult Result of a query
 *
 * @package Firesphere\Solr\Search
 * @author Simon `Firesphere` Erkelens; Marco `Sheepy` Hermo
 * @copyright Copyright (c) 2018 - now() Firesphere & Sheepy
 */

namespace Firesphere\SolrSearch\Results;

use Firesphere\SolrSearch\Indexes\BaseIndex;
use Firesphere\SolrSearch\Queries\BaseQuery;
use Firesphere\SolrSearch\Services\SolrCoreService;
use Firesphere\SolrSearch\Traits\SearchResultGetTrait;
use Firesphere\SolrSearch\Traits\SearchResultSetTrait;
use SilverStripe\Control\Controller;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\PaginatedList;
use SilverStripe\View\ArrayData;
use SilverStripe\View\ViewableData;
use Solarium\Component\Result\Facet\Field;
use Solarium\Component\Result\FacetSet;
use Solarium\Component\Result\Spellcheck\Collation;
use Solarium\Component\Result\Spellcheck\Result as SpellcheckResult;
use Solarium\QueryType\Select\Result\Document;
use Solarium\QueryType\Select\Result\Result;
use stdClass;

/**
 * Class SearchResult is the combined result in a SilverStripe readable way
 *
 * Each of the requested features of a BaseQuery are generated to be easily accessible in the controller.
 * In the controller, each required item can be accessed through the resulting method in this class.
 *
 * @package Firesphere\Solr\Search
 */
class SearchResult extends ViewableData
{
    use SearchResultGetTrait;
    use SearchResultSetTrait;

    /**
     * @var BaseQuery Query that has been executed
     */
    protected $query;
    /**
     * @var BaseIndex Index the query has run on
     */
    protected $index;
    /**
     * @var stdClass|ArrayList|DataList|DataObject Resulting matches from the query on the index
     */
    protected $matches;

    /**
     * SearchResult constructor.
     * Funnily enough, the $result contains the actual results, and has methods for the other things.
     * See Solarium docs for this.
     *
     * @param Result $result
     * @param BaseQuery $query
     * @param BaseIndex $index
     */
    public function __construct(Result $result, BaseQuery $query, BaseIndex $index)
    {
        parent::__construct();
        $this->index = $index;
        $this->query = $query;
        $this->setMatches($result->getDocuments())
            ->setFacets($result->getFacetSet())
            ->setHighlight($result->getHighlighting())
            ->setTotalItems($result->getNumFound());
        if ($query->hasSpellcheck()) {
            $this->setSpellcheck($result->getSpellcheck())
                ->setCollatedSpellcheck($result->getSpellcheck());
        }
    }

    /**
     * Set the facets to build
     *
     * @param FacetSet|null $facets
     * @return $this
     */
    protected function setFacets($facets): self
    {
        $this->facets = $this->buildFacets($facets);

        return $this;
    }

    /**
     * Build the given list of key-value pairs in to a SilverStripe useable array
     *
     * @param FacetSet|null $facets
     * @return ArrayData
     */
    protected function buildFacets($facets): ArrayData
    {
        $facetArray = [];
        if ($facets) {
            $facetTypes = $this->index->getFacetFields();
            // Loop all available facet fields by type
            foreach ($facetTypes as $class => $options) {
                $facetArray = $this->createFacet($facets, $options, $class, $facetArray);
            }
        }

        // Return an ArrayList of the results
        return ArrayData::create($facetArray);
    }

    /**
     * Create facets from each faceted class
     *
     * @param FacetSet $facets
     * @param array $options
     * @param string $class
     * @param array $facetArray
     * @return array
     */
    protected function createFacet($facets, $options, $class, array $facetArray): array
    {
        // Get the facets by its title
        /** @var Field $typeFacets */
        $typeFacets = $facets->getFacet('facet-' . $options['Title']);
        $values = $typeFacets->getValues();
        $results = ArrayList::create();
        // If there are values, get the items one by one and push them in to the list
        if (count($values)) {
            $this->getClassFacets($class, $values, $results);
        }
        // Put the results in to the array
        $facetArray[$options['Title']] = $results;

        return $facetArray;
    }

    /**
     * Get the facets for each class and their count
     *
     * @param $class
     * @param array $values
     * @param ArrayList $results
     */
    protected function getClassFacets($class, array $values, &$results): void
    {
        $items = $class::get()->byIds(array_keys($values));
        foreach ($items as $item) {
            // Set the FacetCount value to be sorted on later
            $item->FacetCount = $values[$item->ID];
            $results->push($item);
        }
        // Sort the results by FacetCount
        $results = $results->sort(['FacetCount' => 'DESC', 'Title' => 'ASC',]);
    }

    /**
     * Set the collated spellcheck string
     *
     * @param mixed $collatedSpellcheck
     * @return $this
     */
    public function setCollatedSpellcheck($collatedSpellcheck): self
    {
        /** @var Collation $collated */
        if (!$this->index->isRetry() && $collatedSpellcheck && ($collated = $collatedSpellcheck->getCollations())) {
            $this->collatedSpellcheck = $collated[0]->getQuery();
        }

        return $this;
    }

    /**
     * Set the spellcheck list as an ArrayList
     *
     * @param SpellcheckResult|null $spellcheck
     * @return SearchResult
     */
    public function setSpellcheck($spellcheck): self
    {
        $spellcheckList = [];

        if ($spellcheck && ($suggestions = $spellcheck->getSuggestion(0))) {
            foreach ($suggestions->getWords() as $suggestion) {
                $spellcheckList[] = ArrayData::create($suggestion);
            }
        }

        $this->spellcheck = ArrayList::create($spellcheckList);

        return $this;
    }

    /**
     * Get the matches as a Paginated List
     *
     * @return PaginatedList
     */
    public function getPaginatedMatches(): PaginatedList
    {
        $request = Controller::curr()->getRequest();
        // Get all the items in the set and push them in to the list
        $items = $this->getMatches();
        /** @var PaginatedList $paginated */
        $paginated = PaginatedList::create($items, $request);
        // Do not limit the pagination, it's done at Solr level
        $paginated->setLimitItems(false)
            // Override the count that's set from the item count
            ->setTotalItems($this->getTotalItems())
            // Set the start to the current page from start.
            ->setPageStart($this->query->getStart())
            // The amount of items per page to display
            ->setPageLength($this->query->getRows());

        return $paginated;
    }

    /**
     * Get the matches as an ArrayList and add an excerpt if possible.
     * {@link static::createExcerpt()}
     *
     * @return ArrayList
     */
    public function getMatches(): ArrayList
    {
        $matches = $this->matches;
        $items = [];
        $idField = SolrCoreService::ID_FIELD;
        $classIDField = SolrCoreService::CLASS_ID_FIELD;
        foreach ($matches as $match) {
            $item = $this->isDataObject($match, $classIDField);
            if ($item !== false) {
                $this->createExcerpt($idField, $match, $item);
                $items[] = $item;
                $item->destroy();
            }
            unset($match);
        }

        return ArrayList::create($items)->limit($this->query->getRows());
    }

    /**
     * Set the matches from Solarium as an ArrayList
     *
     * @param array $result
     * @return $this
     */
    protected function setMatches($result): self
    {
        $data = [];
        /** @var Document $item */
        foreach ($result as $item) {
            $data[] = ArrayData::create($item->getFields());
        }

        $docs = ArrayList::create($data);
        $this->matches = $docs;

        return $this;
    }

    /**
     * Check if the match is a DataObject and exists
     *
     * @param $match
     * @param string $classIDField
     * @return DataObject|bool
     */
    protected function isDataObject($match, string $classIDField)
    {
        if (!$match instanceof DataObject) {
            $class = $match->ClassName;
            /** @var DataObject $match */
            $match = $class::get()->byID($match->{$classIDField});
        }

        return ($match && $match->exists()) ? $match : false;
    }

    /**
     * Generate an excerpt for a DataObject
     *
     * @param string $idField
     * @param $match
     * @param DataObject $item
     */
    protected function createExcerpt(string $idField, $match, DataObject $item): void
    {
        $item->Excerpt = DBField::create_field(
            'HTMLText',
            str_replace(
                '&#65533;',
                '',
                $this->getHighlightByID($match->{$idField})
            )
        );
    }

    /**
     * Get the highlight for a specific document
     *
     * @param $docID
     * @return string
     */
    public function getHighlightByID($docID): string
    {
        $highlights = [];
        if ($this->highlight && $docID) {
            $highlights = [];
            foreach ($this->highlight->getResult($docID) as $field => $highlight) {
                $highlights[] = implode(' (...) ', $highlight);
            }
        }

        return implode(' (...) ', $highlights);
    }

    /**
     * Allow overriding of matches with a custom result. Accepts anything you like, mostly
     *
     * @param stdClass|ArrayList|DataList|DataObject $matches
     * @return mixed
     */
    public function setCustomisedMatches($matches)
    {
        $this->matches = $matches;

        return $matches;
    }
}