Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.96% covered (warning)
66.96%
77 / 115
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Loader
66.96% covered (warning)
66.96%
77 / 115
50.00% covered (danger)
50.00%
3 / 6
140.20
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 load
64.86% covered (warning)
64.86%
24 / 37
0.00% covered (danger)
0.00%
0 / 1
34.66
 loadBatchForSource
52.08% covered (warning)
52.08%
25 / 48
0.00% covered (danger)
0.00%
0 / 1
58.72
 buildMissingRecord
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 loadBatch
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 setCacheContext
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * Record loader
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010, 2022.
9 * Copyright (C) The National Library of Finland 2015.
10 *
11 * This program is free software; you can redistribute it and/or modify
12 * it under the terms of the GNU General Public License version 2,
13 * as published by the Free Software Foundation.
14 *
15 * This program is distributed in the hope that it will be useful,
16 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 * GNU General Public License for more details.
19 *
20 * You should have received a copy of the GNU General Public License
21 * along with this program; if not, write to the Free Software
22 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
23 *
24 * @category VuFind
25 * @package  Record
26 * @author   Demian Katz <demian.katz@villanova.edu>
27 * @author   Ere Maijala <ere.maijala@helsinki.fi>
28 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
29 * @link     https://vufind.org Main Site
30 */
31
32namespace VuFind\Record;
33
34use VuFind\Exception\RecordMissing as RecordMissingException;
35use VuFind\Record\FallbackLoader\PluginManager as FallbackLoader;
36use VuFind\RecordDriver\PluginManager as RecordFactory;
37use VuFindSearch\Backend\Exception\BackendException;
38use VuFindSearch\Command\RetrieveBatchCommand;
39use VuFindSearch\Command\RetrieveCommand;
40use VuFindSearch\ParamBag;
41use VuFindSearch\Service as SearchService;
42
43use function count;
44use function is_object;
45
46/**
47 * Record loader
48 *
49 * @category VuFind
50 * @package  Record
51 * @author   Demian Katz <demian.katz@villanova.edu>
52 * @author   Ere Maijala <ere.maijala@helsinki.fi>
53 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
54 * @link     https://vufind.org Main Site
55 */
56class Loader implements \Laminas\Log\LoggerAwareInterface
57{
58    use \VuFind\Log\LoggerAwareTrait;
59
60    /**
61     * Record factory
62     *
63     * @var RecordFactory
64     */
65    protected $recordFactory;
66
67    /**
68     * Search service
69     *
70     * @var SearchService
71     */
72    protected $searchService;
73
74    /**
75     * Record cache
76     *
77     * @var Cache
78     */
79    protected $recordCache;
80
81    /**
82     * Fallback record loader
83     *
84     * @var FallbackLoader
85     */
86    protected $fallbackLoader;
87
88    /**
89     * Constructor
90     *
91     * @param SearchService  $searchService  Search service
92     * @param RecordFactory  $recordFactory  Record loader
93     * @param Cache          $recordCache    Record Cache
94     * @param FallbackLoader $fallbackLoader Fallback record loader
95     */
96    public function __construct(
97        SearchService $searchService,
98        RecordFactory $recordFactory,
99        Cache $recordCache = null,
100        FallbackLoader $fallbackLoader = null
101    ) {
102        $this->searchService = $searchService;
103        $this->recordFactory = $recordFactory;
104        $this->recordCache = $recordCache;
105        $this->fallbackLoader = $fallbackLoader;
106    }
107
108    /**
109     * Given an ID and record source, load the requested record object.
110     *
111     * @param string   $id              Record ID
112     * @param string   $source          Record source
113     * @param bool     $tolerateMissing Should we load a "Missing" placeholder
114     * instead of throwing an exception if the record cannot be found?
115     * @param ParamBag $params          Search backend parameters
116     *
117     * @throws \Exception
118     * @return \VuFind\RecordDriver\AbstractBase
119     */
120    public function load(
121        $id,
122        $source = DEFAULT_SEARCH_BACKEND,
123        $tolerateMissing = false,
124        ParamBag $params = null
125    ) {
126        if (null !== $id && '' !== $id) {
127            $results = [];
128            if (
129                null !== $this->recordCache
130                && $this->recordCache->isPrimary($source)
131            ) {
132                $results = $this->recordCache->lookup($id, $source);
133            }
134            if (empty($results)) {
135                try {
136                    $command = new RetrieveCommand($source, $id, $params);
137                    $results = $this->searchService->invoke($command)
138                        ->getResult()->getRecords();
139                } catch (BackendException $e) {
140                    if (!$tolerateMissing) {
141                        throw $e;
142                    }
143                }
144            }
145            if (
146                empty($results) && null !== $this->recordCache
147                && $this->recordCache->isFallback($source)
148            ) {
149                $results = $this->recordCache->lookup($id, $source);
150                if (!empty($results)) {
151                    $results[0]->setExtraDetail('cached_record', true);
152                }
153            }
154
155            if (!empty($results)) {
156                return $results[0];
157            }
158
159            if (
160                $this->fallbackLoader
161                && $this->fallbackLoader->has($source)
162            ) {
163                try {
164                    $fallbackRecords = $this->fallbackLoader->get($source)
165                        ->load([$id]);
166                } catch (BackendException $e) {
167                    if (!$tolerateMissing) {
168                        throw $e;
169                    }
170                    $fallbackRecords = [];
171                }
172
173                if (count($fallbackRecords) == 1) {
174                    return $fallbackRecords[0];
175                }
176            }
177        }
178        if ($tolerateMissing) {
179            $record = $this->recordFactory->get('Missing');
180            $record->setRawData(['id' => $id]);
181            $record->setSourceIdentifiers($source);
182            return $record;
183        }
184        throw new RecordMissingException(
185            'Record ' . $source . ':' . $id . ' does not exist.'
186        );
187    }
188
189    /**
190     * Given an array of IDs and a record source, load a batch of records for
191     * that source.
192     *
193     * @param array    $ids                       Record IDs
194     * @param string   $source                    Record source
195     * @param bool     $tolerateBackendExceptions Whether to tolerate backend
196     * exceptions that may be caused by e.g. connection issues or changes in
197     * subscriptions
198     * @param ParamBag $params                    Search backend parameters
199     *
200     * @throws \Exception
201     * @return array
202     */
203    public function loadBatchForSource(
204        $ids,
205        $source = DEFAULT_SEARCH_BACKEND,
206        $tolerateBackendExceptions = false,
207        ParamBag $params = null
208    ) {
209        $list = new Checklist($ids);
210        $cachedRecords = [];
211        if (null !== $this->recordCache && $this->recordCache->isPrimary($source)) {
212            // Try to load records from cache if source is cachable
213            $cachedRecords = $this->recordCache->lookupBatch($ids, $source);
214            // Check which records could not be loaded from the record cache
215            foreach ($cachedRecords as $cachedRecord) {
216                $list->check($cachedRecord->getUniqueId());
217            }
218        }
219
220        // Try to load the uncached records from the original $source
221        $genuineRecords = [];
222        if ($list->hasUnchecked()) {
223            try {
224                $command = new RetrieveBatchCommand(
225                    $source,
226                    $list->getUnchecked(),
227                    $params
228                );
229                $genuineRecords = $this->searchService
230                    ->invoke($command)->getResult()->getRecords();
231            } catch (BackendException $e) {
232                if (!$tolerateBackendExceptions) {
233                    throw $e;
234                }
235                $this->logWarning(
236                    "Exception when trying to retrieve records from $source"
237                    . $e->getMessage()
238                );
239            }
240
241            foreach ($genuineRecords as $genuineRecord) {
242                $list->check($genuineRecord->getUniqueId());
243            }
244        }
245
246        $retVal = $genuineRecords;
247        if (
248            $list->hasUnchecked() && $this->fallbackLoader
249            && $this->fallbackLoader->has($source)
250        ) {
251            try {
252                $fallbackRecords = $this->fallbackLoader->get($source)
253                    ->load($list->getUnchecked());
254            } catch (BackendException $e) {
255                if (!$tolerateBackendExceptions) {
256                    throw $e;
257                }
258                $fallbackRecords = [];
259                $this->logWarning(
260                    'Exception when trying to retrieve fallback records from '
261                    . $source . ': ' . $e->getMessage()
262                );
263            }
264            foreach ($fallbackRecords as $record) {
265                $retVal[] = $record;
266                if (!$list->check($record->getUniqueId())) {
267                    $list->check($record->tryMethod('getPreviousUniqueId'));
268                }
269            }
270        }
271
272        if (
273            $list->hasUnchecked() && null !== $this->recordCache
274            && $this->recordCache->isFallback($source)
275        ) {
276            // Try to load missing records from cache if source is cachable
277            $cachedRecords = $this->recordCache
278                ->lookupBatch($list->getUnchecked(), $source);
279        }
280
281        // Merge records found in cache and records loaded from original $source
282        foreach ($cachedRecords as $cachedRecord) {
283            $retVal[] = $cachedRecord;
284        }
285
286        return $retVal;
287    }
288
289    /**
290     * Build a "missing record" driver.
291     *
292     * @param array $details Associative array of record details (from a
293     * SourceAndIdList)
294     *
295     * @return \VuFind\RecordDriver\Missing
296     */
297    protected function buildMissingRecord($details)
298    {
299        $fields = $details['extra_fields'] ?? [];
300        $fields['id'] = $details['id'];
301        $record = $this->recordFactory->get('Missing');
302        $record->setRawData($fields);
303        $record->setSourceIdentifiers($details['source']);
304        return $record;
305    }
306
307    /**
308     * Given an array of associative arrays with id and source keys (or pipe-
309     * separated source|id strings), load all of the requested records in the
310     * requested order.
311     *
312     * @param array      $ids                       Array of associative arrays with
313     * id/source keys or strings in source|id format. In associative array formats,
314     * there is also an optional "extra_fields" key which can be used to pass in data
315     * formatted as if it belongs to the Solr schema; this is used to create
316     * a mock driver object if the real data source is unavailable.
317     * @param bool       $tolerateBackendExceptions Whether to tolerate backend
318     * exceptions that may be caused by e.g. connection issues or changes in
319     * subscriptions
320     * @param ParamBag[] $params                    Associative array of search
321     * backend parameters keyed with source key
322     *
323     * @throws \Exception
324     * @return array     Array of record drivers
325     */
326    public function loadBatch(
327        $ids,
328        $tolerateBackendExceptions = false,
329        $params = []
330    ) {
331        // Create a SourceAndIdList object to help sort the IDs by source:
332        $list = new SourceAndIdList($ids);
333
334        // Retrieve the records and put them back in order:
335        $retVal = [];
336        foreach ($list->getIdsBySource() as $source => $currentIds) {
337            $sourceParams = $params[$source] ?? null;
338            $records = $this->loadBatchForSource(
339                $currentIds,
340                $source,
341                $tolerateBackendExceptions,
342                $sourceParams
343            );
344            foreach ($records as $current) {
345                foreach ($list->getRecordPositions($current) as $i => $position) {
346                    // If we have multiple positions, create a clone of the driver
347                    // for positions after 0, to avoid shared-reference problems:
348                    $retVal[$position] = $i == 0 ? $current : clone $current;
349                }
350            }
351        }
352
353        // Check for missing records and fill gaps with \VuFind\RecordDriver\Missing
354        // objects:
355        foreach ($list->getAll() as $i => $details) {
356            if (!isset($retVal[$i]) || !is_object($retVal[$i])) {
357                $retVal[$i] = $this->buildMissingRecord($details);
358            }
359        }
360
361        // Send back the final array, with the keys in proper order:
362        ksort($retVal);
363        return $retVal;
364    }
365
366    /**
367     * Set the context to control cache behavior
368     *
369     * @param string $context Cache context
370     *
371     * @return void
372     */
373    public function setCacheContext($context)
374    {
375        if (null !== $this->recordCache) {
376            $this->recordCache->setContext($context);
377        }
378    }
379}