Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
22.22% |
16 / 72 |
|
27.27% |
3 / 11 |
CRAP | |
0.00% |
0 / 1 |
Solr | |
22.22% |
16 / 72 |
|
27.27% |
3 / 11 |
792.81 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setConfig | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
10 | |||
addFilters | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
initSearchObject | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
mungeQuery | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
getSuggestions | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
42 | |||
getSuggestionsFromSearch | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
30 | |||
pickBestMatch | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
90 | |||
setDisplayField | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setSortField | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
matchQueryTerms | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | |
3 | /** |
4 | * Solr Autocomplete Module |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) Villanova University 2010. |
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 Autocomplete |
25 | * @author Demian Katz <demian.katz@villanova.edu> |
26 | * @author Chris Hallberg <challber@villanova.edu> |
27 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
28 | * @link https://vufind.org/wiki/development:plugins:autosuggesters Wiki |
29 | */ |
30 | |
31 | namespace VuFind\Autocomplete; |
32 | |
33 | use function count; |
34 | use function is_array; |
35 | use function is_object; |
36 | |
37 | /** |
38 | * Solr Autocomplete Module |
39 | * |
40 | * This class provides suggestions by using the local Solr index. |
41 | * |
42 | * @category VuFind |
43 | * @package Autocomplete |
44 | * @author Demian Katz <demian.katz@villanova.edu> |
45 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
46 | * @link https://vufind.org/wiki/development:plugins:autosuggesters Wiki |
47 | */ |
48 | class Solr implements AutocompleteInterface |
49 | { |
50 | /** |
51 | * Autocomplete handler |
52 | * |
53 | * @var string |
54 | */ |
55 | protected $handler; |
56 | |
57 | /** |
58 | * Solr field to use for display |
59 | * |
60 | * @var string |
61 | */ |
62 | protected $displayField; |
63 | |
64 | /** |
65 | * Default Solr display field if none is configured |
66 | * |
67 | * @var string |
68 | */ |
69 | protected $defaultDisplayField = 'title'; |
70 | |
71 | /** |
72 | * Solr field to use for sorting |
73 | * |
74 | * @var string |
75 | */ |
76 | protected $sortField; |
77 | |
78 | /** |
79 | * Filters to apply to Solr search |
80 | * |
81 | * @var array |
82 | */ |
83 | protected $filters; |
84 | |
85 | /** |
86 | * Search object family to use |
87 | * |
88 | * @var string |
89 | */ |
90 | protected $searchClassId = 'Solr'; |
91 | |
92 | /** |
93 | * Search results object |
94 | * |
95 | * @var \VuFind\Search\Base\Results |
96 | */ |
97 | protected $searchObject; |
98 | |
99 | /** |
100 | * Results plugin manager |
101 | * |
102 | * @var \VuFind\Search\Results\PluginManager |
103 | */ |
104 | protected $resultsManager; |
105 | |
106 | /** |
107 | * Constructor |
108 | * |
109 | * @param \VuFind\Search\Results\PluginManager $results Results plugin manager |
110 | */ |
111 | public function __construct(\VuFind\Search\Results\PluginManager $results) |
112 | { |
113 | $this->resultsManager = $results; |
114 | } |
115 | |
116 | /** |
117 | * Set parameters that affect the behavior of the autocomplete handler. |
118 | * These values normally come from the search configuration file. |
119 | * |
120 | * @param string $params Parameters to set |
121 | * |
122 | * @return void |
123 | */ |
124 | public function setConfig($params) |
125 | { |
126 | // Save the basic parameters: |
127 | $params = explode(':', $params); |
128 | $this->handler = (isset($params[0]) && !empty($params[0])) ? |
129 | $params[0] : null; |
130 | $this->displayField = (isset($params[1]) && !empty($params[1])) ? |
131 | explode(',', $params[1]) : [$this->defaultDisplayField]; |
132 | $this->sortField = (isset($params[2]) && !empty($params[2])) ? |
133 | $params[2] : null; |
134 | $this->filters = []; |
135 | if (count($params) > 3) { |
136 | for ($x = 3; $x < count($params); $x += 2) { |
137 | if (isset($params[$x + 1])) { |
138 | $this->filters[] = $params[$x] . ':' . $params[$x + 1]; |
139 | } |
140 | } |
141 | } |
142 | |
143 | // Set up the Search Object: |
144 | $this->initSearchObject(); |
145 | } |
146 | |
147 | /** |
148 | * Add filters (in addition to the configured ones) |
149 | * |
150 | * @param array $filters Filters to add |
151 | * |
152 | * @return void |
153 | */ |
154 | public function addFilters($filters) |
155 | { |
156 | $this->filters += $filters; |
157 | } |
158 | |
159 | /** |
160 | * Initialize the search object used for finding recommendations. |
161 | * |
162 | * @return void |
163 | */ |
164 | protected function initSearchObject() |
165 | { |
166 | // Build a new search object: |
167 | $this->searchObject = $this->resultsManager->get($this->searchClassId); |
168 | $this->searchObject->getOptions()->spellcheckEnabled(false); |
169 | } |
170 | |
171 | /** |
172 | * Process the user query to make it suitable for a Solr query. |
173 | * |
174 | * @param string $query Incoming user query |
175 | * |
176 | * @return string Processed query |
177 | */ |
178 | protected function mungeQuery($query) |
179 | { |
180 | // Modify the query so it makes a nice, truncated autocomplete query: |
181 | $forbidden = [':', '(', ')', '*', '+', '"', "'"]; |
182 | $query = str_replace($forbidden, ' ', $query); |
183 | if (!str_ends_with($query, ' ')) { |
184 | $query .= '*'; |
185 | } |
186 | return $query; |
187 | } |
188 | |
189 | /** |
190 | * This method returns an array of strings matching the user's query for |
191 | * display in the autocomplete box. |
192 | * |
193 | * @param string $query The user query |
194 | * |
195 | * @return array The suggestions for the provided query |
196 | */ |
197 | public function getSuggestions($query) |
198 | { |
199 | $results = null; |
200 | if (!is_object($this->searchObject)) { |
201 | throw new \Exception('Please set configuration first.'); |
202 | } |
203 | |
204 | try { |
205 | $this->searchObject->getParams()->setBasicSearch( |
206 | $this->mungeQuery($query), |
207 | $this->handler |
208 | ); |
209 | $this->searchObject->getParams()->setSort($this->sortField); |
210 | foreach ($this->filters as $current) { |
211 | $this->searchObject->getParams()->addFilter($current); |
212 | } |
213 | |
214 | // Perform the search: |
215 | $searchResults = $this->searchObject->getResults(); |
216 | |
217 | // Build the recommendation list -- first we'll try with exact matches; |
218 | // if we don't get anything at all, we'll try again with a less strict |
219 | // set of rules. |
220 | $results = $this->getSuggestionsFromSearch($searchResults, $query, true); |
221 | if (empty($results)) { |
222 | $results = $this->getSuggestionsFromSearch( |
223 | $searchResults, |
224 | $query, |
225 | false |
226 | ); |
227 | } |
228 | } catch (\Exception $e) { |
229 | // Ignore errors -- just return empty results if we must. |
230 | } |
231 | return isset($results) ? array_unique($results) : []; |
232 | } |
233 | |
234 | /** |
235 | * Try to turn an array of record drivers into an array of suggestions. |
236 | * |
237 | * @param array $searchResults An array of record drivers |
238 | * @param string $query User search query |
239 | * @param bool $exact Ignore non-exact matches? |
240 | * |
241 | * @return array |
242 | */ |
243 | protected function getSuggestionsFromSearch($searchResults, $query, $exact) |
244 | { |
245 | $results = []; |
246 | foreach ($searchResults as $object) { |
247 | $current = $object->getRawData(); |
248 | foreach ($this->displayField as $field) { |
249 | if (isset($current[$field])) { |
250 | $bestMatch = $this->pickBestMatch( |
251 | $current[$field], |
252 | $query, |
253 | $exact |
254 | ); |
255 | if ($bestMatch) { |
256 | $results[] = $bestMatch; |
257 | break; |
258 | } |
259 | } |
260 | } |
261 | } |
262 | return $results; |
263 | } |
264 | |
265 | /** |
266 | * Given the values from a Solr field and the user's search query, pick the best |
267 | * match to display as a recommendation. |
268 | * |
269 | * @param array|string $value Field value (or array of field values) |
270 | * @param string $query User search query |
271 | * @param bool $exact Ignore non-exact matches? |
272 | * |
273 | * @return bool|string String to use as recommendation, or false if |
274 | * no appropriate value was found. |
275 | */ |
276 | protected function pickBestMatch($value, $query, $exact) |
277 | { |
278 | // By default, assume no match: |
279 | $bestMatch = false; |
280 | |
281 | // Different processing for arrays vs. non-arrays: |
282 | if (is_array($value) && !empty($value)) { |
283 | // Do any of the values within this multi-valued array match the |
284 | // query? Try to find the closest available match. |
285 | foreach ($value as $next) { |
286 | if ($this->matchQueryTerms($next, $query)) { |
287 | $bestMatch = $next; |
288 | break; |
289 | } |
290 | } |
291 | |
292 | // If we didn't find an exact match, use the first value unless |
293 | // we have the "precise matches only" property set, in which case |
294 | // we don't want to use any of these values. |
295 | if (!$bestMatch && !$exact) { |
296 | $bestMatch = $value[0]; |
297 | } |
298 | } else { |
299 | // If we have a single value, we will use it if we're in non-strict |
300 | // mode OR if we're in strict mode and it actually matches. |
301 | if (!$exact || $this->matchQueryTerms($value, $query)) { |
302 | $bestMatch = $value; |
303 | } |
304 | } |
305 | return $bestMatch; |
306 | } |
307 | |
308 | /** |
309 | * Set the display field list. Useful for child classes. |
310 | * |
311 | * @param array $new Display field list. |
312 | * |
313 | * @return void |
314 | */ |
315 | protected function setDisplayField($new) |
316 | { |
317 | $this->displayField = $new; |
318 | } |
319 | |
320 | /** |
321 | * Set the sort field list. Useful for child classes. |
322 | * |
323 | * @param string $new Sort field list. |
324 | * |
325 | * @return void |
326 | */ |
327 | protected function setSortField($new) |
328 | { |
329 | $this->sortField = $new; |
330 | } |
331 | |
332 | /** |
333 | * Return true if all terms in the query occurs in the field data string. |
334 | * |
335 | * @param string $data The data field returned from solr |
336 | * @param string $query The query string entered by the user |
337 | * |
338 | * @return bool |
339 | */ |
340 | protected function matchQueryTerms($data, $query) |
341 | { |
342 | $terms = preg_split("/\s+/", $query); |
343 | foreach ($terms as $term) { |
344 | if (stripos($data, (string)$term) === false) { |
345 | return false; |
346 | } |
347 | } |
348 | return true; |
349 | } |
350 | } |