Passed
Pull Request — master (#44)
by
unknown
02:34
created

Solr::_setLimit()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 1
1
<?php
2
3
/**
4
 * (c) Kitodo. Key to digital objects e.V. <[email protected]>
5
 *
6
 * This file is part of the Kitodo and TYPO3 projects.
7
 *
8
 * @license GNU General Public License version 3 or later.
9
 * For the full copyright and license information, please read the
10
 * LICENSE.txt file that was distributed with this source code.
11
 */
12
13
namespace Kitodo\Dlf\Common;
14
15
use TYPO3\CMS\Core\Cache\CacheManager;
16
use TYPO3\CMS\Core\Database\ConnectionPool;
17
use TYPO3\CMS\Core\Utility\GeneralUtility;
18
use TYPO3\CMS\Core\Utility\MathUtility;
19
20
/**
21
 * Solr class for the 'dlf' extension
22
 *
23
 * @author Sebastian Meyer <[email protected]>
24
 * @author Henrik Lochmann <[email protected]>
25
 * @package TYPO3
26
 * @subpackage dlf
27
 * @access public
28
 * @property-read string|null $core This holds the core name for the current instance
29
 * @property-write int $cPid This holds the PID for the configuration
30
 * @property int $limit This holds the max results
31
 * @property-read int $numberOfHits This holds the number of hits for last search
32
 * @property-write array $params This holds the additional query parameters
33
 * @property-read bool $ready Is the Solr service instantiated successfully?
34
 * @property-read \Solarium\Client $service This holds the Solr service object
35
 */
36
class Solr
37
{
38
    /**
39
     * This holds the Solr configuration
40
     *
41
     * @var array
42
     * @access protected
43
     */
44
    protected $config = [];
45
46
    /**
47
     * This holds the core name
48
     *
49
     * @var string|null
50
     * @access protected
51
     */
52
    protected $core = null;
53
54
    /**
55
     * This holds the PID for the configuration
56
     *
57
     * @var int
58
     * @access protected
59
     */
60
    protected $cPid = 0;
61
62
    /**
63
     * The extension key
64
     *
65
     * @var string
66
     * @access public
67
     */
68
    public static $extKey = 'dlf';
69
70
    /**
71
     * This holds the max results
72
     *
73
     * @var int
74
     * @access protected
75
     */
76
    protected $limit = 50000;
77
78
    /**
79
     * This holds the number of hits for last search
80
     *
81
     * @var int
82
     * @access protected
83
     */
84
    protected $numberOfHits = 0;
85
86
    /**
87
     * This holds the additional query parameters
88
     *
89
     * @var array
90
     * @access protected
91
     */
92
    protected $params = [];
93
94
    /**
95
     * Is the search instantiated successfully?
96
     *
97
     * @var bool
98
     * @access protected
99
     */
100
    protected $ready = false;
101
102
    /**
103
     * This holds the singleton search objects with their core as array key
104
     *
105
     * @var array (\Kitodo\Dlf\Common\Solr)
106
     * @access protected
107
     */
108
    protected static $registry = [];
109
110
    /**
111
     * This holds the Solr service object
112
     *
113
     * @var \Solarium\Client
114
     * @access protected
115
     */
116
    protected $service;
117
118
    /**
119
     * Add a new core to Apache Solr
120
     *
121
     * @access public
122
     *
123
     * @param string $core: The name of the new core. If empty, the next available core name is used.
124
     *
125
     * @return string The name of the new core
126
     */
127
    public static function createCore($core = '')
128
    {
129
        // Get next available core name if none given.
130
        if (empty($core)) {
131
            $core = 'dlfCore' . self::getNextCoreNumber();
132
        }
133
        // Get Solr service instance.
134
        $solr = self::getInstance($core);
135
        // Create new core if core with given name doesn't exist.
136
        if ($solr->ready) {
137
            // Core already exists.
138
            return $core;
139
        } else {
140
            // Core doesn't exist yet.
141
            $solrAdmin = self::getInstance();
142
            if ($solrAdmin->ready) {
143
                $query = $solrAdmin->service->createCoreAdmin();
144
                $action = $query->createCreate();
145
                $action->setConfigSet('dlf');
146
                $action->setCore($core);
147
                $action->setDataDir('data');
148
                $action->setInstanceDir($core);
149
                $query->setAction($action);
150
                try {
151
                    $response = $solrAdmin->service->coreAdmin($query);
152
                    if ($response->getWasSuccessful()) {
153
                        // Core successfully created.
154
                        return $core;
155
                    }
156
                } catch (\Exception $e) {
157
                    // Nothing to do here.
158
                }
159
            } else {
160
                Helper::devLog('Apache Solr not available', DEVLOG_SEVERITY_ERROR);
161
            }
162
        }
163
        return '';
164
    }
165
166
    /**
167
     * Escape all special characters in a query string
168
     *
169
     * @access public
170
     *
171
     * @param string $query: The query string
172
     *
173
     * @return string The escaped query string
174
     */
175
    public static function escapeQuery($query)
176
    {
177
        $helper = GeneralUtility::makeInstance(\Solarium\Core\Query\Helper::class);
178
        // Escape query phrase or term.
179
        if (preg_match('/^".*"$/', $query)) {
180
            return $helper->escapePhrase(trim($query, '"'));
181
        } else {
182
            // Using a modified escape function here to retain whitespace, '*' and '?' for search truncation.
183
            // @see https://github.com/solariumphp/solarium/blob/5.x/src/Core/Query/Helper.php#L70 for reference
184
            /* return $helper->escapeTerm($query); */
185
            return preg_replace('/(\+|-|&&|\|\||!|\(|\)|\{|}|\[|]|\^|"|~|:|\/|\\\)/', '\\\$1', $query);
186
        }
187
    }
188
189
    /**
190
     * Escape all special characters in a query string while retaining valid field queries
191
     *
192
     * @access public
193
     *
194
     * @param string $query: The query string
195
     * @param int $pid: The PID for the field configuration
196
     *
197
     * @return string The escaped query string
198
     */
199
    public static function escapeQueryKeepField($query, $pid)
200
    {
201
        // Is there a field query?
202
        if (preg_match('/^[[:alnum:]]+_[tu][su]i:\(?.*\)?$/', $query)) {
203
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
204
                ->getQueryBuilderForTable('tx_dlf_metadata');
205
206
            // Get all indexed fields.
207
            $fields = [];
208
            $result = $queryBuilder
209
                ->select(
210
                    'tx_dlf_metadata.index_name AS index_name',
211
                    'tx_dlf_metadata.index_tokenized AS index_tokenized',
212
                    'tx_dlf_metadata.index_stored AS index_stored'
213
                )
214
                ->from('tx_dlf_metadata')
215
                ->where(
216
                    $queryBuilder->expr()->eq('tx_dlf_metadata.index_indexed', 1),
217
                    $queryBuilder->expr()->eq('tx_dlf_metadata.pid', intval($pid)),
218
                    $queryBuilder->expr()->orX(
219
                        $queryBuilder->expr()->in('tx_dlf_metadata.sys_language_uid', [-1, 0]),
220
                        $queryBuilder->expr()->eq('tx_dlf_metadata.l18n_parent', 0)
221
                    ),
222
                    Helper::whereExpression('tx_dlf_metadata')
223
                )
224
                ->execute();
225
226
            while ($resArray = $result->fetch()) {
227
                $fields[] = $resArray['index_name'] . '_' . ($resArray['index_tokenized'] ? 't' : 'u') . ($resArray['index_stored'] ? 's' : 'u') . 'i';
228
            }
229
230
            // Check if queried field is valid.
231
            $splitQuery = explode(':', $query, 2);
232
            if (in_array($splitQuery[0], $fields)) {
233
                $query = $splitQuery[0] . ':(' . self::escapeQuery(trim($splitQuery[1], '()')) . ')';
234
            } else {
235
                $query = self::escapeQuery($query);
236
            }
237
        } else {
238
            $query = self::escapeQuery($query);
239
        }
240
        return $query;
241
    }
242
243
    /**
244
     * This is a singleton class, thus instances must be created by this method
245
     *
246
     * @access public
247
     *
248
     * @param mixed $core: Name or UID of the core to load or null to get core admin endpoint
249
     *
250
     * @return \Kitodo\Dlf\Common\Solr Instance of this class
251
     */
252
    public static function getInstance($core = null)
253
    {
254
        // Get core name if UID is given.
255
        if (MathUtility::canBeInterpretedAsInteger($core)) {
256
            $core = Helper::getIndexNameFromUid($core, 'tx_dlf_solrcores');
257
        }
258
        // Check if core is set or null.
259
        if (
260
            empty($core)
261
            && $core !== null
262
        ) {
263
            Helper::devLog('Invalid core UID or name given for Apache Solr', DEVLOG_SEVERITY_ERROR);
264
        }
265
        if (!empty($core)) {
266
            // Check if there is an instance in the registry already.
267
            if (
268
                is_object(self::$registry[$core])
269
                && self::$registry[$core] instanceof self
270
            ) {
271
                // Return singleton instance if available.
272
                return self::$registry[$core];
273
            }
274
        }
275
        // Create new instance...
276
        $instance = new self($core);
277
        // ...and save it to registry.
278
        if (!empty($instance->core)) {
279
            self::$registry[$instance->core] = $instance;
280
        }
281
        return $instance;
282
    }
283
284
    /**
285
     * Get next unused Solr core number
286
     *
287
     * @access public
288
     *
289
     * @param int $number: Number to start with
290
     *
291
     * @return int First unused core number found
292
     */
293
    public static function getNextCoreNumber($number = 0)
294
    {
295
        $number = max(intval($number), 0);
296
        // Check if core already exists.
297
        $solr = self::getInstance('dlfCore' . $number);
298
        if (!$solr->ready) {
299
            return $number;
300
        } else {
301
            return self::getNextCoreNumber($number + 1);
302
        }
303
    }
304
305
    /**
306
     * Sets the connection information for Solr
307
     *
308
     * @access protected
309
     *
310
     * @return void
311
     */
312
    protected function loadSolrConnectionInfo()
313
    {
314
        if (empty($this->config)) {
315
            $config = [];
316
            // Extract extension configuration.
317
            $conf = unserialize($GLOBALS['TYPO3_CONF_VARS']['EXT']['extConf'][self::$extKey]);
318
            // Derive Solr scheme
319
            $config['scheme'] = empty($conf['solrHttps']) ? 'http' : 'https';
320
            // Derive Solr host name.
321
            $config['host'] = ($conf['solrHost'] ? $conf['solrHost'] : '127.0.0.1');
322
            // Set username and password.
323
            $config['username'] = $conf['solrUser'];
324
            $config['password'] = $conf['solrPass'];
325
            // Set port if not set.
326
            $config['port'] = MathUtility::forceIntegerInRange($conf['solrPort'], 1, 65535, 8983);
327
            // Trim path of slashes and (re-)add trailing slash if path not empty.
328
            $config['path'] = trim($conf['solrPath'], '/');
329
            if (!empty($config['path'])) {
330
                $config['path'] .= '/';
331
            }
332
            // Set connection timeout lower than PHP's max_execution_time.
333
            $max_execution_time = intval(ini_get('max_execution_time')) ?: 30;
334
            $config['timeout'] = MathUtility::forceIntegerInRange($conf['solrTimeout'], 1, $max_execution_time, 10);
335
            $this->config = $config;
336
        }
337
    }
338
339
    /**
340
     * Processes a search request.
341
     *
342
     * @access public
343
     *
344
     * @return \Kitodo\Dlf\Common\DocumentList The result list
345
     */
346
    public function search()
347
    {
348
        $toplevel = [];
349
        // Take over query parameters.
350
        $params = $this->params;
0 ignored issues
show
introduced by
The property params is declared write-only in Kitodo\Dlf\Common\Solr.
Loading history...
351
        $params['filterquery'] = isset($params['filterquery']) ? $params['filterquery'] : [];
352
        // Set some query parameters.
353
        $params['start'] = 0;
354
        $params['rows'] = 0;
355
        // Perform search to determine the total number of hits without fetching them.
356
        $selectQuery = $this->service->createSelect($params);
357
        $results = $this->service->select($selectQuery);
358
        $this->numberOfHits = $results->getNumFound();
0 ignored issues
show
Bug introduced by
The property numberOfHits is declared read-only in Kitodo\Dlf\Common\Solr.
Loading history...
359
        // Restore query parameters
360
        $params = $this->params;
361
        $params['filterquery'] = isset($params['filterquery']) ? $params['filterquery'] : [];
362
        // Restrict the fields to the required ones.
363
        $params['fields'] = 'uid,id';
364
        // Extend filter query to get all documents with the same uids.
365
        foreach ($params['filterquery'] as $key => $value) {
366
            if (isset($value['query'])) {
367
                $params['filterquery'][$key]['query'] = '{!join from=uid to=uid}' . $value['query'];
368
            }
369
        }
370
        // Set filter query to just get toplevel documents.
371
        $params['filterquery'][] = ['query' => 'toplevel:true'];
372
        // Set join query to get all documents with the same uids.
373
        $params['query'] = '{!join from=uid to=uid}' . $params['query'];
374
        // Perform search to determine the total number of toplevel hits and fetch the required rows.
375
        $selectQuery = $this->service->createSelect($params);
376
        $results = $this->service->select($selectQuery);
377
        $numberOfToplevelHits = $results->getNumFound();
378
        // Process results.
379
        foreach ($results as $doc) {
380
            $toplevel[$doc->id] = [
381
                'u' => $doc->uid,
382
                'h' => '',
383
                's' => '',
384
                'p' => []
385
            ];
386
        }
387
        // Save list of documents.
388
        $list = GeneralUtility::makeInstance(DocumentList::class);
389
        $list->reset();
390
        $list->add(array_values($toplevel));
391
        // Set metadata for search.
392
        $list->metadata = [
393
            'label' => '',
394
            'description' => '',
395
            'options' => [
396
                'source' => 'search',
397
                'engine' => 'solr',
398
                'select' => $this->params['query'],
399
                'userid' => 0,
400
                'params' => $this->params,
401
                'core' => $this->core,
402
                'pid' => $this->cPid,
0 ignored issues
show
introduced by
The property cPid is declared write-only in Kitodo\Dlf\Common\Solr.
Loading history...
403
                'order' => 'score',
404
                'order.asc' => true,
405
                'numberOfHits' => $this->numberOfHits,
406
                'numberOfToplevelHits' => $numberOfToplevelHits
407
            ]
408
        ];
409
        return $list;
410
    }
411
412
    /**
413
     * Processes a search request and returns the raw Apache Solr Documents.
414
     *
415
     * @access public
416
     *
417
     * @param string $query: The search query
418
     * @param array $parameters: Additional search parameters
419
     *
420
     * @return array The Apache Solr Documents that were fetched
421
     */
422
    public function search_raw($query = '', $parameters = [])
423
    {
424
        // Set additional query parameters.
425
        $parameters['start'] = 0;
426
        $parameters['rows'] = $this->limit;
427
        // Set query.
428
        $parameters['query'] = $query;
429
430
        // calculate cache identifier
431
        $cacheIdentifier = hash('md5', print_r(array_merge($this->params, $parameters), 1));
0 ignored issues
show
introduced by
The property params is declared write-only in Kitodo\Dlf\Common\Solr.
Loading history...
432
        $cache = GeneralUtility::makeInstance(CacheManager::class)->getCache('tx_dlf_solr');
433
434
        $resultSet = [];
435
        if (($entry = $cache->get($cacheIdentifier)) === false) {
436
            $selectQuery = $this->service->createSelect(array_merge($this->params, $parameters));
437
            $result = $this->service->select($selectQuery);
438
            foreach ($result as $doc) {
439
                $resultSet[] = $doc;
440
            }
441
            // Save value in cache
442
            $cache->set($cacheIdentifier, $resultSet);
443
        } else {
444
            // return cache hit
445
            $resultSet = $entry;
446
        }
447
        return $resultSet;
448
    }
449
450
    /**
451
     * This returns $this->core via __get()
452
     *
453
     * @access protected
454
     *
455
     * @return string|null The core name of the current query endpoint or null if core admin endpoint
456
     */
457
    protected function _getCore()
458
    {
459
        return $this->core;
460
    }
461
462
    /**
463
     * This returns $this->limit via __get()
464
     *
465
     * @access protected
466
     *
467
     * @return int The max number of results
468
     */
469
    protected function _getLimit()
470
    {
471
        return $this->limit;
472
    }
473
474
    /**
475
     * This returns $this->numberOfHits via __get()
476
     *
477
     * @access protected
478
     *
479
     * @return int Total number of hits for last search
480
     */
481
    protected function _getNumberOfHits()
482
    {
483
        return $this->numberOfHits;
484
    }
485
486
    /**
487
     * This returns $this->ready via __get()
488
     *
489
     * @access protected
490
     *
491
     * @return bool Is the search instantiated successfully?
492
     */
493
    protected function _getReady()
494
    {
495
        return $this->ready;
496
    }
497
498
    /**
499
     * This returns $this->service via __get()
500
     *
501
     * @access protected
502
     *
503
     * @return \Solarium\Client Apache Solr service object
504
     */
505
    protected function _getService()
506
    {
507
        return $this->service;
508
    }
509
510
    /**
511
     * This sets $this->cPid via __set()
512
     *
513
     * @access protected
514
     *
515
     * @param int $value: The new PID for the metadata definitions
516
     *
517
     * @return void
518
     */
519
    protected function _setCPid($value)
520
    {
521
        $this->cPid = max(intval($value), 0);
522
    }
523
524
    /**
525
     * This sets $this->limit via __set()
526
     *
527
     * @access protected
528
     *
529
     * @param int $value: The max number of results
530
     *
531
     * @return void
532
     */
533
    protected function _setLimit($value)
534
    {
535
        $this->limit = max(intval($value), 0);
536
    }
537
538
    /**
539
     * This sets $this->params via __set()
540
     *
541
     * @access protected
542
     *
543
     * @param array $value: The query parameters
544
     *
545
     * @return void
546
     */
547
    protected function _setParams(array $value)
548
    {
549
        $this->params = $value;
550
    }
551
552
    /**
553
     * This magic method is called each time an invisible property is referenced from the object
554
     *
555
     * @access public
556
     *
557
     * @param string $var: Name of variable to get
558
     *
559
     * @return mixed Value of $this->$var
560
     */
561
    public function __get($var)
562
    {
563
        $method = '_get' . ucfirst($var);
564
        if (
565
            !property_exists($this, $var)
566
            || !method_exists($this, $method)
567
        ) {
568
            Helper::devLog('There is no getter function for property "' . $var . '"', DEVLOG_SEVERITY_WARNING);
569
            return;
570
        } else {
571
            return $this->$method();
572
        }
573
    }
574
575
    /**
576
     * This magic method is called each time an invisible property is checked for isset() or empty()
577
     *
578
     * @access public
579
     *
580
     * @param string $var: Name of variable to check
581
     *
582
     * @return bool true if variable is set and not empty, false otherwise
583
     */
584
    public function __isset($var)
585
    {
586
        return !empty($this->__get($var));
587
    }
588
589
    /**
590
     * This magic method is called each time an invisible property is referenced from the object
591
     *
592
     * @access public
593
     *
594
     * @param string $var: Name of variable to set
595
     * @param mixed $value: New value of variable
596
     *
597
     * @return void
598
     */
599
    public function __set($var, $value)
600
    {
601
        $method = '_set' . ucfirst($var);
602
        if (
603
            !property_exists($this, $var)
604
            || !method_exists($this, $method)
605
        ) {
606
            Helper::devLog('There is no setter function for property "' . $var . '"', DEVLOG_SEVERITY_WARNING);
607
        } else {
608
            $this->$method($value);
609
        }
610
    }
611
612
    /**
613
     * This is a singleton class, thus the constructor should be private/protected
614
     *
615
     * @access protected
616
     *
617
     * @param string|null $core: The name of the core to use or null for core admin endpoint
618
     *
619
     * @return void
620
     */
621
    protected function __construct($core)
622
    {
623
        // Get Solr connection parameters from configuration.
624
        $this->loadSolrConnectionInfo();
625
        // Configure connection adapter.
626
        $adapter = GeneralUtility::makeInstance(\Solarium\Core\Client\Adapter\Http::class);
627
        $adapter->setTimeout($this->config['timeout']);
628
        // Configure event dispatcher.
629
            // Todo: When updating to TYPO3 >=10.x and Solarium >=6.x
630
            // we have to provide an PSR-14 Event Dispatcher instead of
631
            // "null".
632
            // $eventDispatcher = GeneralUtility::makeInstance(\TYPO3\CMS\Core\EventDispatcher\EventDispatcher::class);
633
        $eventDispatcher = null;
634
        // Configure endpoint.
635
        $config = [
636
            'endpoint' => [
637
                'default' => [
638
                    'scheme' => $this->config['scheme'],
639
                    'host' => $this->config['host'],
640
                    'port' => $this->config['port'],
641
                    'path' => '/' . $this->config['path'],
642
                    'core' => $core,
643
                    'username' => $this->config['username'],
644
                    'password' => $this->config['password']
645
                ]
646
            ]
647
        ];
648
        // Instantiate Solarium\Client class.
649
        $this->service = GeneralUtility::makeInstance(\Solarium\Client::class, $adapter, $eventDispatcher, $config);
0 ignored issues
show
Bug introduced by
The property service is declared read-only in Kitodo\Dlf\Common\Solr.
Loading history...
650
        // Check if connection is established.
651
        $query = $this->service->createCoreAdmin();
652
        $action = $query->createStatus();
653
        if ($core !== null) {
654
            $action->setCore($core);
655
        }
656
        $query->setAction($action);
657
        try {
658
            $response = $this->service->coreAdmin($query);
659
            if ($response->getWasSuccessful()) {
660
                // Solr is reachable, but is the core as well?
661
                if ($core !== null) {
662
                    $result = $response->getStatusResult();
663
                    if (
664
                        $result instanceof \Solarium\QueryType\Server\CoreAdmin\Result\StatusResult
665
                        && $result->getUptime() > 0
666
                    ) {
667
                        // Set core name.
668
                        $this->core = $core;
0 ignored issues
show
Bug introduced by
The property core is declared read-only in Kitodo\Dlf\Common\Solr.
Loading history...
669
                    } else {
670
                        // Core not available.
671
                        return;
672
                    }
673
                }
674
                // Instantiation successful!
675
                $this->ready = true;
0 ignored issues
show
Bug introduced by
The property ready is declared read-only in Kitodo\Dlf\Common\Solr.
Loading history...
676
            }
677
        } catch (\Exception $e) {
678
            // Nothing to do here.
679
        }
680
    }
681
}
682