Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.43% covered (warning)
87.43%
160 / 183
92.00% covered (success)
92.00%
23 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Backend
87.43% covered (warning)
87.43%
160 / 183
92.00% covered (success)
92.00%
23 / 25
73.39
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setPageSize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 search
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 rawJsonSearch
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getExtraRequestDetails
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 resetExtraRequestDetails
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIds
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 random
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 retrieve
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 retrieveBatch
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 similar
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 terms
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
9
 alphabeticBrowse
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 writeDocument
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 setQueryBuilder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getQueryBuilder
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setSimilarBuilder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSimilarBuilder
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getRecordCollectionFactory
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getConnector
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createRecordCollection
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 deserialize
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 refineBrowseException
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 injectResponseWriter
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 workKeysSearch
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2
3/**
4 * SOLR backend.
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  Search
25 * @author   David Maus <maus@hab.de>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org
28 */
29
30namespace VuFindSearch\Backend\Solr;
31
32use VuFindSearch\Backend\AbstractBackend;
33use VuFindSearch\Backend\Exception\BackendException;
34use VuFindSearch\Backend\Exception\RemoteErrorException;
35use VuFindSearch\Backend\Solr\Document\DocumentInterface;
36use VuFindSearch\Backend\Solr\Response\Json\Terms;
37use VuFindSearch\Exception\InvalidArgumentException;
38use VuFindSearch\Feature\ExtraRequestDetailsInterface;
39use VuFindSearch\Feature\GetIdsInterface;
40use VuFindSearch\Feature\RandomInterface;
41use VuFindSearch\Feature\RetrieveBatchInterface;
42use VuFindSearch\Feature\SimilarInterface;
43use VuFindSearch\ParamBag;
44use VuFindSearch\Query\AbstractQuery;
45use VuFindSearch\Query\WorkKeysQuery;
46use VuFindSearch\Response\RecordCollectionFactoryInterface;
47use VuFindSearch\Response\RecordCollectionInterface;
48
49use function count;
50use function is_int;
51use function sprintf;
52
53/**
54 * SOLR backend.
55 *
56 * @category VuFind
57 * @package  Search
58 * @author   David Maus <maus@hab.de>
59 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
60 * @link     https://vufind.org
61 */
62class Backend extends AbstractBackend implements
63    SimilarInterface,
64    RetrieveBatchInterface,
65    RandomInterface,
66    ExtraRequestDetailsInterface,
67    GetIdsInterface
68{
69    /**
70     * Limit for records per query in a batch retrieval.
71     *
72     * @var int
73     */
74    protected $pageSize = 100;
75
76    /**
77     * Connector.
78     *
79     * @var Connector
80     */
81    protected $connector;
82
83    /**
84     * Query builder.
85     *
86     * @var QueryBuilder
87     */
88    protected $queryBuilder = null;
89
90    /**
91     * Similar records query builder.
92     *
93     * @var SimilarBuilder
94     */
95    protected $similarBuilder = null;
96
97    /**
98     * Constructor.
99     *
100     * @param Connector $connector SOLR connector
101     *
102     * @return void
103     */
104    public function __construct(Connector $connector)
105    {
106        $this->connector    = $connector;
107        $this->identifier   = null;
108    }
109
110    /**
111     * Set the limit for batch queries
112     *
113     * @param int $pageSize Records per Query
114     *
115     * @return void
116     */
117    public function setPageSize($pageSize)
118    {
119        $this->pageSize = $pageSize;
120    }
121
122    /**
123     * Perform a search and return record collection.
124     *
125     * @param AbstractQuery $query  Search query
126     * @param int           $offset Search offset
127     * @param int           $limit  Search limit
128     * @param ParamBag      $params Search backend parameters
129     *
130     * @return RecordCollectionInterface
131     */
132    public function search(
133        AbstractQuery $query,
134        $offset,
135        $limit,
136        ParamBag $params = null
137    ) {
138        if ($query instanceof WorkKeysQuery) {
139            return $this->workKeysSearch($query, $offset, $limit, $params);
140        }
141        $json = $this->rawJsonSearch($query, $offset, $limit, $params);
142        $collection = $this->createRecordCollection($json);
143        $this->injectSourceIdentifier($collection);
144
145        return $collection;
146    }
147
148    /**
149     * Perform a search and return a raw response.
150     *
151     * @param AbstractQuery $query  Search query
152     * @param int           $offset Search offset
153     * @param int           $limit  Search limit
154     * @param ParamBag      $params Search backend parameters
155     *
156     * @return string
157     */
158    public function rawJsonSearch(
159        AbstractQuery $query,
160        $offset,
161        $limit,
162        ParamBag $params = null
163    ) {
164        $params = $params ?: new ParamBag();
165        $this->injectResponseWriter($params);
166
167        $params->set('rows', $limit);
168        $params->set('start', $offset);
169        $params->mergeWith($this->getQueryBuilder()->build($query, $params));
170        return $this->connector->search($params);
171    }
172
173    /**
174     * Returns some extra details about the search.
175     *
176     * @return array
177     */
178    public function getExtraRequestDetails()
179    {
180        return [
181            'solrRequestUrl' => $this->connector->getLastUrl(),
182        ];
183    }
184
185    /**
186     * Clears all accumulated extra request details
187     *
188     * @return void
189     */
190    public function resetExtraRequestDetails()
191    {
192        $this->connector->resetLastUrl();
193    }
194
195    /**
196     * Perform a search and return record collection of only record identifiers.
197     *
198     * @param AbstractQuery $query  Search query
199     * @param int           $offset Search offset
200     * @param int           $limit  Search limit
201     * @param ParamBag      $params Search backend parameters
202     *
203     * @return RecordCollectionInterface
204     */
205    public function getIds(
206        AbstractQuery $query,
207        $offset,
208        $limit,
209        ParamBag $params = null
210    ) {
211        $params = $params ?: new ParamBag();
212        $this->injectResponseWriter($params);
213
214        $params->set('rows', $limit);
215        $params->set('start', $offset);
216        $flParts = [$this->getConnector()->getUniqueKey()];
217        if ($fl = $params->get('fl')) {
218            // Merge multiple values if necessary, then split on delimiter:
219            $flParts = array_unique(array_merge($flParts, explode(',', implode(',', $fl))));
220        }
221        $params->set('fl', implode(',', $flParts));
222        $params->mergeWith($this->getQueryBuilder()->build($query));
223        $response   = $this->connector->search($params);
224        $collection = $this->createRecordCollection($response);
225        $this->injectSourceIdentifier($collection);
226
227        return $collection;
228    }
229
230    /**
231     * Get Random records
232     *
233     * @param AbstractQuery $query  Search query
234     * @param int           $limit  Search limit
235     * @param ParamBag      $params Search backend parameters
236     *
237     * @return RecordCollectionInterface
238     */
239    public function random(
240        AbstractQuery $query,
241        $limit,
242        ParamBag $params = null
243    ) {
244        $params = $params ?: new ParamBag();
245        $this->injectResponseWriter($params);
246
247        $random = rand(0, 1000000);
248        $sort = "{$random}_random asc";
249        $params->set('sort', $sort);
250
251        return $this->search($query, 0, $limit, $params);
252    }
253
254    /**
255     * Retrieve a single document.
256     *
257     * @param string   $id     Document identifier
258     * @param ParamBag $params Search backend parameters
259     *
260     * @return RecordCollectionInterface
261     */
262    public function retrieve($id, ParamBag $params = null)
263    {
264        $params = $params ?: new ParamBag();
265        $this->injectResponseWriter($params);
266
267        $response   = $this->connector->retrieve($id, $params);
268        $collection = $this->createRecordCollection($response);
269        $this->injectSourceIdentifier($collection);
270        return $collection;
271    }
272
273    /**
274     * Retrieve a batch of documents.
275     *
276     * @param array    $ids    Array of document identifiers
277     * @param ParamBag $params Search backend parameters
278     *
279     * @return RecordCollectionInterface
280     */
281    public function retrieveBatch($ids, ParamBag $params = null)
282    {
283        $params = $params ?: new ParamBag();
284
285        // Callback function for formatting IDs:
286        $formatIds = function ($i) {
287            return '"' . addcslashes($i, '"') . '"';
288        };
289
290        // Retrieve records a page at a time:
291        $results = false;
292        while (count($ids) > 0) {
293            $currentPage = array_splice($ids, 0, $this->pageSize, []);
294            $currentPage = array_map($formatIds, $currentPage);
295            $params->set('q', 'id:(' . implode(' OR ', $currentPage) . ')');
296            $params->set('start', 0);
297            $params->set('rows', $this->pageSize);
298            $this->injectResponseWriter($params);
299            $next = $this->createRecordCollection(
300                $this->connector->search($params)
301            );
302            if (!$results) {
303                $results = $next;
304            } else {
305                foreach ($next->getRecords() as $record) {
306                    $results->add($record);
307                }
308            }
309        }
310        $this->injectSourceIdentifier($results);
311        return $results;
312    }
313
314    /**
315     * Return similar records.
316     *
317     * @param string   $id     Id of record to compare with
318     * @param ParamBag $params Search backend parameters
319     *
320     * @return RecordCollectionInterface
321     */
322    public function similar($id, ParamBag $params = null)
323    {
324        $params = $params ?: new ParamBag();
325        $this->injectResponseWriter($params);
326
327        $params->mergeWith($this->getSimilarBuilder()->build($id));
328        $response   = $this->connector->similar($id, $params);
329        $collection = $this->createRecordCollection($response);
330        $this->injectSourceIdentifier($collection);
331        return $collection;
332    }
333
334    /**
335     * Return terms from SOLR index.
336     *
337     * @param string   $field  Index field
338     * @param string   $start  Starting term (blank for beginning of list)
339     * @param int      $limit  Maximum number of terms
340     * @param ParamBag $params Additional parameters
341     *
342     * @return Terms
343     */
344    public function terms(
345        $field = null,
346        $start = null,
347        $limit = null,
348        ParamBag $params = null
349    ) {
350        // Support alternate syntax with ParamBag as first parameter:
351        if ($field instanceof ParamBag && $params === null) {
352            $params = $field;
353            $field = null;
354        }
355
356        // Create empty ParamBag if none provided:
357        $params = $params ?: new ParamBag();
358        $this->injectResponseWriter($params);
359
360        // Always enable terms:
361        $params->set('terms', 'true');
362
363        // Use parameters if provided:
364        if (null !== $field) {
365            $params->set('terms.fl', $field);
366        }
367        if (null !== $start) {
368            $params->set('terms.lower', $start);
369        }
370        if (null !== $limit) {
371            $params->set('terms.limit', $limit);
372        }
373
374        // Set defaults unless overridden:
375        if (!$params->hasParam('terms.lower.incl')) {
376            $params->set('terms.lower.incl', 'false');
377        }
378        if (!$params->hasParam('terms.sort')) {
379            $params->set('terms.sort', 'index');
380        }
381
382        $response = $this->connector->terms($params);
383        $terms = new Terms($this->deserialize($response));
384        return $terms;
385    }
386
387    /**
388     * Obtain information from an alphabetic browse index.
389     *
390     * @param string   $source      Name of index to search
391     * @param string   $from        Starting point for browse results
392     * @param int      $page        Result page to return (starts at 0)
393     * @param int      $limit       Number of results to return on each page
394     * @param ParamBag $params      Additional parameters
395     * @param int      $offsetDelta Delta to use when calculating page
396     * offset (useful for showing a few results above the highlighted row)
397     *
398     * @return array
399     */
400    public function alphabeticBrowse(
401        $source,
402        $from,
403        $page,
404        $limit = 20,
405        $params = null,
406        $offsetDelta = 0
407    ) {
408        $params = $params ?: new ParamBag();
409        $this->injectResponseWriter($params);
410
411        $params->set('from', $from);
412        $params->set('offset', ($page * $limit) + $offsetDelta);
413        $params->set('rows', $limit);
414        $params->set('source', $source);
415
416        $response = null;
417        try {
418            $response = $this->connector->query('browse', $params);
419        } catch (RemoteErrorException $e) {
420            $this->refineBrowseException($e);
421        }
422        return $this->deserialize($response);
423    }
424
425    /**
426     * Write a document to Solr. Return an array of details about the updated index.
427     *
428     * @param DocumentInterface $doc     Document to write
429     * @param ?int              $timeout Timeout value (null for default)
430     * @param string            $handler Handler to use
431     * @param ?ParamBag         $params  Search backend parameters
432     *
433     * @return array
434     */
435    public function writeDocument(
436        DocumentInterface $doc,
437        int $timeout = null,
438        string $handler = 'update',
439        ?ParamBag $params = null
440    ) {
441        $connector = $this->getConnector();
442
443        // Write!
444        $connector->callWithHttpOptions(
445            is_int($timeout ?? null) ? compact('timeout') : [],
446            'write',
447            $doc,
448            $handler,
449            $params
450        );
451
452        // Save the core name in the results in case the caller needs it.
453        return ['core' => $connector->getCore()];
454    }
455
456    /**
457     * Set the query builder.
458     *
459     * @param QueryBuilder $queryBuilder Query builder
460     *
461     * @return void
462     */
463    public function setQueryBuilder(QueryBuilder $queryBuilder)
464    {
465        $this->queryBuilder = $queryBuilder;
466    }
467
468    /**
469     * Return query builder.
470     *
471     * Lazy loads an empty default QueryBuilder if none was set.
472     *
473     * @return QueryBuilder
474     */
475    public function getQueryBuilder()
476    {
477        if (!$this->queryBuilder) {
478            $this->queryBuilder = new QueryBuilder();
479        }
480        return $this->queryBuilder;
481    }
482
483    /**
484     * Set the similar records query builder.
485     *
486     * @param SimilarBuilder $similarBuilder Similar builder
487     *
488     * @return void
489     */
490    public function setSimilarBuilder(SimilarBuilder $similarBuilder)
491    {
492        $this->similarBuilder = $similarBuilder;
493    }
494
495    /**
496     * Return similar records query builder.
497     *
498     * Lazy loads an empty default SimilarBuilder if none was set.
499     *
500     * @return SimilarBuilder
501     */
502    public function getSimilarBuilder()
503    {
504        if (!$this->similarBuilder) {
505            $this->similarBuilder = new SimilarBuilder();
506        }
507        return $this->similarBuilder;
508    }
509
510    /**
511     * Return the record collection factory.
512     *
513     * Lazy loads a generic collection factory.
514     *
515     * @return RecordCollectionFactoryInterface
516     */
517    public function getRecordCollectionFactory()
518    {
519        if (!$this->collectionFactory) {
520            $this->collectionFactory = new Response\Json\RecordCollectionFactory();
521        }
522        return $this->collectionFactory;
523    }
524
525    /**
526     * Return the SOLR connector.
527     *
528     * @return Connector
529     */
530    public function getConnector()
531    {
532        return $this->connector;
533    }
534
535    /// Internal API
536
537    /**
538     * Create record collection.
539     *
540     * @param string $json Serialized JSON response
541     *
542     * @return RecordCollectionInterface
543     */
544    protected function createRecordCollection($json)
545    {
546        return $this->getRecordCollectionFactory()
547            ->factory($this->deserialize($json));
548    }
549
550    /**
551     * Deserialize JSON response.
552     *
553     * @param string $json Serialized JSON response
554     *
555     * @return array
556     *
557     * @throws BackendException Deserialization error
558     */
559    protected function deserialize($json)
560    {
561        $response = json_decode($json, true);
562        $error    = json_last_error();
563        if ($error != \JSON_ERROR_NONE) {
564            throw new BackendException(
565                sprintf('JSON decoding error: %s -- %s', $error, $json)
566            );
567        }
568        $qtime = $response['responseHeader']['QTime'] ?? 'n/a';
569        $this->log('debug', 'Deserialized SOLR response', ['qtime' => $qtime]);
570        return $response;
571    }
572
573    /**
574     * Improve the exception message for alphaBrowse errors when appropriate.
575     *
576     * @param RemoteErrorException $e Exception to clean up
577     *
578     * @return void
579     * @throws RemoteErrorException
580     */
581    protected function refineBrowseException(RemoteErrorException $e)
582    {
583        $error = $e->getMessage() . $e->getResponse();
584        if (
585            strstr($error, 'does not exist') || strstr($error, 'no such table')
586            || strstr($error, 'couldn\'t find a browse index')
587        ) {
588            throw new RemoteErrorException(
589                'Alphabetic Browse index missing.  See ' .
590                'https://vufind.org/wiki/indexing:alphabetical_heading_browse for ' .
591                'details on generating the index.',
592                $e->getCode(),
593                $e->getResponse(),
594                $e->getPrevious()
595            );
596        }
597        throw $e;
598    }
599
600    /**
601     * Inject response writer and named list implementation into parameters.
602     *
603     * @param ParamBag $params Parameters
604     *
605     * @return void
606     *
607     * @throws InvalidArgumentException Response writer and named list
608     * implementation already set to an incompatible type.
609     */
610    protected function injectResponseWriter(ParamBag $params)
611    {
612        if (array_diff($params->get('wt') ?: [], ['json'])) {
613            throw new InvalidArgumentException(
614                sprintf(
615                    'Invalid response writer type: %s',
616                    implode(', ', $params->get('wt'))
617                )
618            );
619        }
620        if (array_diff($params->get('json.nl') ?: [], ['arrarr'])) {
621            throw new InvalidArgumentException(
622                sprintf(
623                    'Invalid named list implementation type: %s',
624                    implode(', ', $params->get('json.nl'))
625                )
626            );
627        }
628        $params->set('wt', ['json']);
629        $params->set('json.nl', ['arrarr']);
630    }
631
632    /**
633     * Return work expressions.
634     *
635     * @param WorkKeysQuery $query         Search query
636     * @param int           $offset        Search offset
637     * @param int           $limit         Search limit
638     * @param ParamBag      $defaultParams Search backend parameters
639     *
640     * @return RecordCollectionInterface
641     */
642    protected function workKeysSearch(
643        WorkKeysQuery $query,
644        int $offset,
645        int $limit,
646        ParamBag $defaultParams = null
647    ): RecordCollectionInterface {
648        $id = $query->getId();
649        if ('' === $id) {
650            throw new BackendException('Record ID empty in work keys query');
651        }
652        if (!($workKeys = $query->getWorkKeys())) {
653            $recordResponse = $this->connector->retrieve($id);
654            $recordCollection = $this->createRecordCollection($recordResponse);
655            $record = $recordCollection->first();
656            if (!$record || !($workKeys = $record->tryMethod('getWorkKeys'))) {
657                return $this->createRecordCollection('{}');
658            }
659        }
660
661        $params = $defaultParams ? clone $defaultParams : new \VuFindSearch\ParamBag();
662        $this->injectResponseWriter($params);
663        $params->set('q', "{!terms f=work_keys_str_mv separator=\"\u{001f}\"}" . implode("\u{001f}", $workKeys));
664        if (!$query->getIncludeSelf()) {
665            $params->add('fq', sprintf('-id:"%s"', addcslashes($id, '"')));
666        }
667        $params->set('rows', $limit);
668        $params->set('start', $offset);
669        if (!$params->hasParam('sort')) {
670            $params->add('sort', 'publishDateSort desc, title_sort asc');
671        }
672        $response = $this->connector->search($params);
673        $collection = $this->createRecordCollection($response);
674        $this->injectSourceIdentifier($collection);
675        return $collection;
676    }
677}