Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 125 |
|
0.00% |
0 / 11 |
CRAP | |
0.00% |
0 / 1 |
Solr | |
0.00% |
0 / 125 |
|
0.00% |
0 / 11 |
1406 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
getXML | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDefaultSearchParams | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
searchSolrLegacy | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
searchSolrCursor | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
20 | |||
searchSolr | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getMapForHierarchy | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
56 | |||
getRecord | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
getJSON | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getFormattedData | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
132 | |||
supports | |
0.00% |
0 / 6 |
|
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 | |
30 | namespace VuFind\Hierarchy\TreeDataSource; |
31 | |
32 | use VuFind\Hierarchy\TreeDataFormatter\PluginManager as FormatterManager; |
33 | use VuFindSearch\Backend\Solr\Command\RawJsonSearchCommand; |
34 | use VuFindSearch\ParamBag; |
35 | use VuFindSearch\Query\Query; |
36 | use VuFindSearch\Service; |
37 | |
38 | use 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 | */ |
51 | class 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 | } |