Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 125
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Solr
0.00% covered (danger)
0.00%
0 / 125
0.00% covered (danger)
0.00%
0 / 11
1406
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getXML
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultSearchParams
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 searchSolrLegacy
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 searchSolrCursor
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
20
 searchSolr
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getMapForHierarchy
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 getRecord
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getJSON
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFormattedData
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
132
 supports
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3/**
4 * Hierarchy Tree Data Source (Solr)
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  HierarchyTree_DataSource
25 * @author   Luke O'Sullivan <l.osullivan@swansea.ac.uk>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development:plugins:hierarchy_components Wiki
28 */
29
30namespace VuFind\Hierarchy\TreeDataSource;
31
32use VuFind\Hierarchy\TreeDataFormatter\PluginManager as FormatterManager;
33use VuFindSearch\Backend\Solr\Command\RawJsonSearchCommand;
34use VuFindSearch\ParamBag;
35use VuFindSearch\Query\Query;
36use VuFindSearch\Service;
37
38use function count;
39
40/**
41 * Hierarchy Tree Data Source (Solr)
42 *
43 * This is a base helper class for producing hierarchy Trees.
44 *
45 * @category VuFind
46 * @package  HierarchyTree_DataSource
47 * @author   Luke O'Sullivan <l.osullivan@swansea.ac.uk>
48 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
49 * @link     https://vufind.org/wiki/development:plugins:hierarchy_components Wiki
50 */
51class Solr extends AbstractBase
52{
53    /**
54     * Search service
55     *
56     * @var Service
57     */
58    protected $searchService;
59
60    /**
61     * Search backend ID used for tree generation.
62     *
63     * @var string
64     */
65    protected $backendId;
66
67    /**
68     * Formatter manager
69     *
70     * @var FormatterManager
71     */
72    protected $formatterManager;
73
74    /**
75     * Cache directory
76     *
77     * @var string
78     */
79    protected $cacheDir = null;
80
81    /**
82     * Filter queries
83     *
84     * @var array
85     */
86    protected $filters = [];
87
88    /**
89     * Record batch size
90     *
91     * @var int
92     */
93    protected $batchSize = 1000;
94
95    /**
96     * Hierarchy cache file prefix.
97     *
98     * @var string
99     */
100    protected $cachePrefix = null;
101
102    /**
103     * Constructor.
104     *
105     * @param Service          $ss        Search service
106     * @param string           $backendId Search backend ID
107     * @param FormatterManager $fm        Formatter manager
108     * @param string           $cacheDir  Directory to hold cache results (optional)
109     * @param array            $filters   Filters to apply to Solr tree queries
110     * @param int              $batchSize Number of records retrieved in a batch
111     */
112    public function __construct(
113        Service $ss,
114        string $backendId,
115        FormatterManager $fm,
116        $cacheDir = null,
117        $filters = [],
118        $batchSize = 1000
119    ) {
120        $this->searchService = $ss;
121        $this->backendId = $backendId;
122        $this->formatterManager = $fm;
123        if (null !== $cacheDir) {
124            $this->cacheDir = rtrim($cacheDir, '/');
125        }
126        $this->filters = $filters;
127        $this->batchSize = $batchSize;
128    }
129
130    /**
131     * Get XML for the specified hierarchy ID.
132     *
133     * Build the XML file from the Solr fields
134     *
135     * @param string $id      Hierarchy ID.
136     * @param array  $options Additional options for XML generation. (Currently one
137     * option is supported: 'refresh' may be set to true to bypass caching).
138     *
139     * @return string
140     */
141    public function getXML($id, $options = [])
142    {
143        return $this->getFormattedData($id, 'xml', $options, 'hierarchyTree_%s.xml');
144    }
145
146    /**
147     * Get default search parameters shared by cursorMark and legacy methods.
148     *
149     * @return array
150     */
151    protected function getDefaultSearchParams(): array
152    {
153        return [
154            'fq' => $this->filters,
155            'hl' => ['false'],
156            'fl' => ['title,id,hierarchy_parent_id,hierarchy_top_id,'
157                . 'is_hierarchy_id,hierarchy_sequence,title_in_hierarchy'],
158            'wt' => ['json'],
159            'json.nl' => ['arrarr'],
160        ];
161    }
162
163    /**
164     * Search Solr using legacy, non-cursorMark method (sometimes needed for
165     * backward compatibility, but usually disabled).
166     *
167     * @param Query $query Search query
168     * @param int   $rows  Page size
169     *
170     * @return array
171     */
172    protected function searchSolrLegacy(Query $query, $rows): array
173    {
174        $params = new ParamBag($this->getDefaultSearchParams());
175        $command = new RawJsonSearchCommand(
176            $this->backendId,
177            $query,
178            0,
179            $rows,
180            $params
181        );
182        $json = $this->searchService->invoke($command)->getResult();
183        return $json->response->docs ?? [];
184    }
185
186    /**
187     * Search Solr using a cursor.
188     *
189     * @param Query $query Search query
190     * @param int   $rows  Page size
191     *
192     * @return array
193     */
194    protected function searchSolrCursor(Query $query, $rows): array
195    {
196        $prevCursorMark = '';
197        $cursorMark = '*';
198        $records = [];
199        while ($cursorMark !== $prevCursorMark) {
200            $params = new ParamBag(
201                $this->getDefaultSearchParams() + [
202                    // Sort is required
203                    'sort' => ['id asc'],
204                    // Override any default timeAllowed since it cannot be used with
205                    // cursorMark
206                    'timeAllowed' => -1,
207                    'cursorMark' => $cursorMark,
208                ]
209            );
210            $command = new RawJsonSearchCommand(
211                $this->backendId,
212                $query,
213                0, // Start is always 0 when using cursorMark
214                min([$this->batchSize, $rows]),
215                $params
216            );
217            $results = $this->searchService->invoke($command)->getResult();
218            if (empty($results->response->docs)) {
219                break;
220            }
221            $records = array_merge($records, $results->response->docs);
222            if (count($records) >= $rows) {
223                break;
224            }
225            $prevCursorMark = $cursorMark;
226            $cursorMark = $results->nextCursorMark;
227        }
228        return $records;
229    }
230
231    /**
232     * Search Solr.
233     *
234     * @param string $q    Search query
235     * @param int    $rows Max rows to retrieve (default = int max / 2 since Solr
236     * may choke with higher values)
237     *
238     * @return array
239     */
240    protected function searchSolr($q, $rows = 1073741823): array
241    {
242        $query = new Query($q);
243        // If batch size is zero or negative, use legacy, non-cursor method:
244        return $this->batchSize <= 0
245            ? $this->searchSolrLegacy($query, $rows)
246            : $this->searchSolrCursor($query, $rows);
247    }
248
249    /**
250     * Retrieve a map of children for the provided hierarchy.
251     *
252     * @param string $id Record ID
253     *
254     * @return array
255     */
256    protected function getMapForHierarchy($id)
257    {
258        // Static cache of last map; if the user requests the same map twice
259        // in a row (as when generating XML and JSON in sequence) this will
260        // save a Solr hit.
261        static $map;
262        static $lastId = null;
263        if ($id === $lastId) {
264            return $map;
265        }
266        $lastId = $id;
267
268        $records = $this->searchSolr('hierarchy_top_id:"' . $id . '"');
269        if (!$records) {
270            return [];
271        }
272        $map = [$id => []];
273        foreach ($records as $current) {
274            $parents = $current->hierarchy_parent_id ?? [];
275            foreach ($parents as $parentId) {
276                if ($current->id === $parentId) {
277                    // Ignore circular reference
278                    continue;
279                }
280                if (!isset($map[$parentId])) {
281                    $map[$parentId] = [$current];
282                } else {
283                    $map[$parentId][] = $current;
284                }
285            }
286        }
287        return $map;
288    }
289
290    /**
291     * Get a record from Solr (return false if not found).
292     *
293     * @param string $id ID to fetch.
294     *
295     * @return array|bool
296     */
297    protected function getRecord($id)
298    {
299        // Static cache of last record; if the user requests the same map twice
300        // in a row (as when generating XML and JSON in sequence) this will
301        // save a Solr hit.
302        static $record;
303        static $lastId = null;
304        if ($id === $lastId) {
305            return $record;
306        }
307        $lastId = $id;
308
309        $records = $this->searchSolr('id:"' . $id . '"', 1);
310        $record = $records ? $records[0] : false;
311        return $record;
312    }
313
314    /**
315     * Get JSON for the specified hierarchy ID.
316     *
317     * Build the JSON file from the Solr fields
318     *
319     * @param string $id      Hierarchy ID.
320     * @param array  $options Additional options for JSON generation. (Currently one
321     * option is supported: 'refresh' may be set to true to bypass caching).
322     *
323     * @return string
324     */
325    public function getJSON($id, $options = [])
326    {
327        return $this->getFormattedData($id, 'json', $options, 'tree_%s.json');
328    }
329
330    /**
331     * Get formatted data for the specified hierarchy ID.
332     *
333     * @param string $id            Hierarchy ID.
334     * @param string $format        Name of formatter service to use.
335     * @param array  $options       Additional options for JSON generation.
336     * (Currently one option is supported: 'refresh' may be set to true to
337     * bypass caching).
338     * @param string $cacheTemplate Template for cache filenames
339     *
340     * @return string
341     */
342    public function getFormattedData(
343        $id,
344        $format,
345        $options = [],
346        $cacheTemplate = 'tree_%s'
347    ) {
348        $cacheFile = (null !== $this->cacheDir)
349            ? $this->cacheDir . '/'
350              . ($this->cachePrefix ? "{$this->cachePrefix}_" : '')
351              . sprintf($cacheTemplate, urlencode($id))
352            : false;
353
354        $useCache = isset($options['refresh']) ? !$options['refresh'] : true;
355        $cacheTime = $this->getHierarchyDriver()->getTreeCacheTime();
356
357        if (
358            $useCache && file_exists($cacheFile)
359            && ($cacheTime < 0 || filemtime($cacheFile) > (time() - $cacheTime))
360        ) {
361            $this->debug("Using cached data from $cacheFile");
362            $json = file_get_contents($cacheFile);
363            return $json;
364        } else {
365            $starttime = microtime(true);
366            $map = $this->getMapForHierarchy($id);
367            if (empty($map)) {
368                return '';
369            }
370            // Get top record's info
371            $formatter = $this->formatterManager->get($format);
372            $formatter->setRawData(
373                $this->getRecord($id),
374                $map,
375                $this->getHierarchyDriver()->treeSorting(),
376                $this->getHierarchyDriver()->getCollectionLinkType()
377            );
378            $encoded = $formatter->getData();
379            $count = $formatter->getCount();
380
381            $this->debug('Done: ' . abs(microtime(true) - $starttime));
382
383            if ($cacheFile) {
384                // Write file
385                if (!file_exists($this->cacheDir)) {
386                    mkdir($this->cacheDir);
387                }
388                file_put_contents($cacheFile, $encoded);
389            }
390            $this->debug(
391                "Hierarchy of {$count} records built in " .
392                abs(microtime(true) - $starttime)
393            );
394            return $encoded;
395        }
396    }
397
398    /**
399     * Does this data source support the specified hierarchy ID?
400     *
401     * @param string $id Hierarchy ID.
402     *
403     * @return bool
404     */
405    public function supports($id)
406    {
407        $settings = $this->hierarchyDriver->getTreeSettings();
408
409        if (
410            !isset($settings['checkAvailability'])
411            || $settings['checkAvailability'] == 1
412        ) {
413            if (!$this->getRecord($id)) {
414                return false;
415            }
416        }
417        // If we got this far the support-check was positive in any case.
418        return true;
419    }
420}