Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
30.40% covered (danger)
30.40%
38 / 125
35.71% covered (danger)
35.71%
5 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Results
30.40% covered (danger)
30.40%
38 / 125
35.71% covered (danger)
35.71%
5 / 14
579.45
0.00% covered (danger)
0.00%
0 / 1
 getSpellingProcessor
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setSpellingProcessor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCursorMark
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCursorMark
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getScores
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getMaxScore
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 performSearch
64.44% covered (warning)
64.44%
29 / 45
0.00% covered (danger)
0.00%
0 / 1
9.20
 fixBadQuery
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 fixBadQueryGroup
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getSpellingSuggestions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getFacetList
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getFilteredFacetCounts
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getPartialFieldFacets
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
110
 getPivotFacetList
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * Solr aspect of the Search Multi-class (Results)
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2011, 2022.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Search_Solr
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org Main Page
28 */
29
30namespace VuFind\Search\Solr;
31
32use VuFind\Search\Solr\AbstractErrorListener as ErrorListener;
33use VuFindSearch\Command\SearchCommand;
34use VuFindSearch\Query\AbstractQuery;
35use VuFindSearch\Query\QueryGroup;
36
37use function count;
38
39/**
40 * Solr Search Parameters
41 *
42 * @category VuFind
43 * @package  Search_Solr
44 * @author   Demian Katz <demian.katz@villanova.edu>
45 * @author   David Maus <maus@hab.de>
46 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
47 * @link     https://vufind.org Main Page
48 */
49class Results extends \VuFind\Search\Base\Results
50{
51    /**
52     * Field facets.
53     *
54     * @var array
55     */
56    protected $responseFacets = null;
57
58    /**
59     * Query facets.
60     *
61     * @var array
62     */
63    protected $responseQueryFacets = null;
64
65    /**
66     * Pivot facets.
67     *
68     * @var array
69     */
70    protected $responsePivotFacets = null;
71
72    /**
73     * Counts of filtered-out facet values, indexed by field name.
74     */
75    protected $filteredFacetCounts = null;
76
77    /**
78     * Search backend identifier.
79     *
80     * @var string
81     */
82    protected $backendId = 'Solr';
83
84    /**
85     * Currently used spelling query, if any.
86     *
87     * @var string
88     */
89    protected $spellingQuery = '';
90
91    /**
92     * Class to process spelling.
93     *
94     * @var SpellingProcessor
95     */
96    protected $spellingProcessor = null;
97
98    /**
99     * CursorMark used for deep paging (e.g. OAI-PMH Server).
100     * Set to '*' to start paging a request and use the new value returned from the
101     * search request for the next request.
102     *
103     * @var null|string
104     */
105    protected $cursorMark = null;
106
107    /**
108     * Highest relevance of all the results
109     *
110     * @var null|float
111     */
112    protected $maxScore = null;
113
114    /**
115     * Get spelling processor.
116     *
117     * @return SpellingProcessor
118     */
119    public function getSpellingProcessor()
120    {
121        if (null === $this->spellingProcessor) {
122            $this->spellingProcessor = new SpellingProcessor();
123        }
124        return $this->spellingProcessor;
125    }
126
127    /**
128     * Set spelling processor.
129     *
130     * @param SpellingProcessor $processor Spelling processor
131     *
132     * @return void
133     */
134    public function setSpellingProcessor(SpellingProcessor $processor)
135    {
136        $this->spellingProcessor = $processor;
137    }
138
139    /**
140     * Get cursorMark.
141     *
142     * @return null|string
143     */
144    public function getCursorMark()
145    {
146        return $this->cursorMark;
147    }
148
149    /**
150     * Set cursorMark.
151     *
152     * @param null|string $cursorMark New cursor mark
153     *
154     * @return void
155     */
156    public function setCursorMark($cursorMark)
157    {
158        $this->cursorMark = $cursorMark;
159    }
160
161    /**
162     * Get the scores of the results
163     *
164     * @return array
165     */
166    public function getScores()
167    {
168        $scoreMap = [];
169        foreach ($this->results as $record) {
170            $data = $record->getRawData();
171            if ($data['score'] ?? false) {
172                $scoreMap[$record->getUniqueId()] = $data['score'];
173            }
174        }
175        return $scoreMap;
176    }
177
178    /**
179     * Getting the highest relevance of all the results
180     *
181     * @return null|float
182     */
183    public function getMaxScore()
184    {
185        return $this->maxScore;
186    }
187
188    /**
189     * Support method for performAndProcessSearch -- perform a search based on the
190     * parameters passed to the object.
191     *
192     * @return void
193     */
194    protected function performSearch()
195    {
196        $query  = $this->getParams()->getQuery();
197        $limit  = $this->getParams()->getLimit();
198        $offset = $this->getStartRecord() - 1;
199        $params = $this->getParams()->getBackendParameters();
200        $searchService = $this->getSearchService();
201        $cursorMark = $this->getCursorMark();
202        if (null !== $cursorMark) {
203            $params->set('cursorMark', '' === $cursorMark ? '*' : $cursorMark);
204            // Override any default timeAllowed since it cannot be used with
205            // cursorMark
206            $params->set('timeAllowed', -1);
207        }
208
209        try {
210            $command = new SearchCommand(
211                $this->backendId,
212                $query,
213                $offset,
214                $limit,
215                $params
216            );
217
218            $collection = $searchService->invoke($command)->getResult();
219        } catch (\VuFindSearch\Backend\Exception\BackendException $e) {
220            // If the query caused a parser error, see if we can clean it up:
221            if (
222                $e->hasTag(ErrorListener::TAG_PARSER_ERROR)
223                && $newQuery = $this->fixBadQuery($query)
224            ) {
225                // We need to get a fresh set of $params, since the previous one was
226                // manipulated by the previous search() call.
227                $params = $this->getParams()->getBackendParameters();
228                $command = new SearchCommand(
229                    $this->backendId,
230                    $newQuery,
231                    $offset,
232                    $limit,
233                    $params
234                );
235                $collection = $searchService->invoke($command)->getResult();
236            } else {
237                throw $e;
238            }
239        }
240
241        $this->extraSearchBackendDetails = $command->getExtraRequestDetails();
242
243        $this->responseFacets = $collection->getFacets();
244        $this->filteredFacetCounts = $collection->getFilteredFacetCounts();
245        $this->responseQueryFacets = $collection->getQueryFacets();
246        $this->responsePivotFacets = $collection->getPivotFacets();
247        $this->resultTotal = $collection->getTotal();
248        $this->maxScore = $collection->getMaxScore();
249
250        // Process spelling suggestions
251        $spellcheck = $collection->getSpellcheck();
252        $this->spellingQuery = $spellcheck->getQuery();
253        $this->suggestions = $this->getSpellingProcessor()
254            ->getSuggestions($spellcheck, $this->getParams()->getQuery());
255
256        // Update current cursorMark
257        if (null !== $cursorMark) {
258            $this->setCursorMark($collection->getCursorMark());
259        }
260
261        // Construct record drivers for all the items in the response:
262        $this->results = $collection->getRecords();
263
264        // Store any errors:
265        $this->errors = $collection->getErrors();
266    }
267
268    /**
269     * Try to fix a query that caused a parser error.
270     *
271     * @param AbstractQuery $query Bad query
272     *
273     * @return bool|AbstractQuery  Fixed query, or false if no solution is found.
274     */
275    protected function fixBadQuery(AbstractQuery $query)
276    {
277        if ($query instanceof QueryGroup) {
278            return $this->fixBadQueryGroup($query);
279        } else {
280            // Single query? Can we fix it on its own?
281            $oldString = $string = $query->getString();
282
283            // Are there any unescaped colons in the string?
284            $string = str_replace(':', '\\:', str_replace('\\:', ':', $string));
285
286            // Did we change anything? If so, we should replace the query:
287            if ($oldString != $string) {
288                $query->setString($string);
289                return $query;
290            }
291        }
292        return false;
293    }
294
295    /**
296     * Support method for fixBadQuery().
297     *
298     * @param QueryGroup $query Query to fix
299     *
300     * @return bool|QueryGroup  Fixed query, or false if no solution is found.
301     */
302    protected function fixBadQueryGroup(QueryGroup $query)
303    {
304        $newQueries = [];
305        $fixed = false;
306
307        // Try to fix each query in the group; replace any query that needs to
308        // be changed.
309        foreach ($query->getQueries() as $current) {
310            $fixedQuery = $this->fixBadQuery($current);
311            if ($fixedQuery) {
312                $fixed = true;
313                $newQueries[] = $fixedQuery;
314            } else {
315                $newQueries[] = $current;
316            }
317        }
318
319        // If any of the queries in the group was fixed, we'll treat the whole
320        // group as being fixed.
321        if ($fixed) {
322            $query->setQueries($newQueries);
323            return $query;
324        }
325
326        // If we got this far, nothing was changed -- report failure:
327        return false;
328    }
329
330    /**
331     * Turn the list of spelling suggestions into an array of urls
332     *   for on-screen use to implement the suggestions.
333     *
334     * @return array Spelling suggestion data arrays
335     */
336    public function getSpellingSuggestions()
337    {
338        return $this->getSpellingProcessor()->processSuggestions(
339            $this->getRawSuggestions(),
340            $this->spellingQuery,
341            $this->getParams()
342        );
343    }
344
345    /**
346     * Returns the stored list of facets for the last search
347     *
348     * @param array $filter Array of field => on-screen description listing
349     * all of the desired facet fields; set to null to get all configured values.
350     *
351     * @return array        Facets data arrays
352     */
353    public function getFacetList($filter = null)
354    {
355        if (null === $this->responseFacets) {
356            $this->performAndProcessSearch();
357        }
358        return $this->buildFacetList($this->responseFacets, $filter);
359    }
360
361    /**
362     * Get counts of facet values filtered out by the HideFacetValueListener,
363     * indexed by field name.
364     *
365     * @return array
366     */
367    public function getFilteredFacetCounts(): array
368    {
369        if (null === $this->filteredFacetCounts) {
370            $this->performAndProcessSearch();
371        }
372        return $this->filteredFacetCounts;
373    }
374
375    /**
376     * Get complete facet counts for several index fields
377     *
378     * @param array  $facetfields  name of the Solr fields to return facets for
379     * @param bool   $removeFilter Clear existing filters from selected fields (true)
380     * or retain them (false)?
381     * @param int    $limit        A limit for the number of facets returned, this
382     * may be useful for very large amounts of facets that can break the JSON parse
383     * method because of PHP out of memory exceptions (default = -1, no limit).
384     * @param string $facetSort    A facet sort value to use (null to retain current)
385     * @param int    $page         1 based. Offsets results by limit.
386     * @param bool   $ored         Whether or not facet is an OR facet or not
387     *
388     * @return array list facet values for each index field with label and more bool
389     */
390    public function getPartialFieldFacets(
391        $facetfields,
392        $removeFilter = true,
393        $limit = -1,
394        $facetSort = null,
395        $page = null,
396        $ored = false
397    ) {
398        $clone = clone $this;
399        $params = $clone->getParams();
400
401        // Manipulate facet settings temporarily:
402        $params->resetFacetConfig();
403        $params->setFacetLimit($limit);
404        // Clear field-specific limits, as they can interfere with retrieval:
405        $params->setFacetLimitByField([]);
406        if (null !== $page && $limit != -1) {
407            $offset = ($page - 1) * $limit;
408            $params->setFacetOffset($offset);
409            // Return limit plus one so we know there's another page
410            $params->setFacetLimit($limit + 1);
411        }
412        if (null !== $facetSort) {
413            $params->setFacetSort($facetSort);
414        }
415        foreach ($facetfields as $facetName) {
416            $params->addFacet($facetName, null, $ored);
417
418            // Clear existing filters for the selected field if necessary:
419            if ($removeFilter) {
420                $params->removeAllFilters($facetName);
421            }
422        }
423
424        // Don't waste time on spellcheck:
425        $params->getOptions()->spellcheckEnabled(false);
426
427        // Don't fetch any records:
428        $params->setLimit(0);
429
430        // Disable highlighting:
431        $params->getOptions()->disableHighlighting();
432
433        // Disable sort:
434        $params->setSort('', true);
435
436        // Do search
437        $result = $clone->getFacetList();
438        $filteredCounts = $clone->getFilteredFacetCounts();
439
440        // Reformat into a hash:
441        foreach ($result as $key => $value) {
442            // Detect next page and crop results if necessary
443            $more = false;
444            if (
445                isset($page) && count($value['list']) > 0
446                && (count($value['list']) + ($filteredCounts[$key] ?? 0)) == $limit + 1
447            ) {
448                $more = true;
449                array_pop($value['list']);
450            }
451            $result[$key] = ['more' => $more, 'data' => $value];
452        }
453
454        // Send back data:
455        return $result;
456    }
457
458    /**
459     * Returns data on pivot facets for the last search
460     *
461     * @return ArrayObject        Flare-formatted object
462     */
463    public function getPivotFacetList()
464    {
465        // Make sure we have processed the search before proceeding:
466        if (null === $this->responseFacets) {
467            $this->performAndProcessSearch();
468        }
469
470        // Start building the flare object:
471        $flare = new \stdClass();
472        $flare->name = 'flare';
473        $flare->total = $this->resultTotal;
474        $flare->children = $this->responsePivotFacets;
475        return $flare;
476    }
477}