Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
22.22% covered (danger)
22.22%
16 / 72
27.27% covered (danger)
27.27%
3 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Solr
22.22% covered (danger)
22.22%
16 / 72
27.27% covered (danger)
27.27%
3 / 11
792.81
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setConfig
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
10
 addFilters
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 initSearchObject
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 mungeQuery
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getSuggestions
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 getSuggestionsFromSearch
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 pickBestMatch
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
90
 setDisplayField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSortField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 matchQueryTerms
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
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
31namespace VuFind\Autocomplete;
32
33use function count;
34use function is_array;
35use 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 */
48class 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}