Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
30.40% |
38 / 125 |
|
35.71% |
5 / 14 |
CRAP | |
0.00% |
0 / 1 |
Results | |
30.40% |
38 / 125 |
|
35.71% |
5 / 14 |
579.45 | |
0.00% |
0 / 1 |
getSpellingProcessor | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
setSpellingProcessor | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCursorMark | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setCursorMark | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getScores | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
getMaxScore | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
performSearch | |
64.44% |
29 / 45 |
|
0.00% |
0 / 1 |
9.20 | |||
fixBadQuery | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
fixBadQueryGroup | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
20 | |||
getSpellingSuggestions | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
getFacetList | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getFilteredFacetCounts | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getPartialFieldFacets | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
110 | |||
getPivotFacetList | |
0.00% |
0 / 7 |
|
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 | |
30 | namespace VuFind\Search\Solr; |
31 | |
32 | use VuFind\Search\Solr\AbstractErrorListener as ErrorListener; |
33 | use VuFindSearch\Command\SearchCommand; |
34 | use VuFindSearch\Query\AbstractQuery; |
35 | use VuFindSearch\Query\QueryGroup; |
36 | |
37 | use 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 | */ |
49 | class 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 | } |