Passed
Push — hans/upgrades ( fad2b0...5115e7 )
by Simon
11:10 queued 06:11
created

BaseIndex::isRetry()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
1
<?php
2
/**
3
 * class BaseIndex|Firesphere\SolrSearch\Indexes\BaseIndex is the base for indexing items
4
 *
5
 * @package Firesphere\Solr\Search
6
 * @author Simon `Firesphere` Erkelens; Marco `Sheepy` Hermo
7
 * @copyright Copyright (c) 2018 - now() Firesphere & Sheepy
8
 */
9
10
namespace Firesphere\SolrSearch\Indexes;
11
12
use Exception;
13
use Firesphere\SolrSearch\Factories\QueryComponentFactory;
14
use Firesphere\SolrSearch\Factories\SchemaFactory;
15
use Firesphere\SolrSearch\Helpers\SolrLogger;
16
use Firesphere\SolrSearch\Helpers\Synonyms;
17
use Firesphere\SolrSearch\Interfaces\ConfigStore;
18
use Firesphere\SolrSearch\Models\SearchSynonym;
19
use Firesphere\SolrSearch\Queries\BaseQuery;
20
use Firesphere\SolrSearch\Results\SearchResult;
21
use Firesphere\SolrSearch\Services\SolrCoreService;
22
use Firesphere\SolrSearch\States\SiteState;
23
use Firesphere\SolrSearch\Traits\BaseIndexTrait;
24
use Firesphere\SolrSearch\Traits\GetterSetterTrait;
25
use GuzzleHttp\Exception\GuzzleException;
26
use LogicException;
27
use ReflectionException;
28
use SilverStripe\Control\Director;
29
use SilverStripe\Core\Config\Config;
30
use SilverStripe\Core\Config\Configurable;
31
use SilverStripe\Core\Extensible;
32
use SilverStripe\Core\Injector\Injectable;
33
use SilverStripe\Core\Injector\Injector;
34
use SilverStripe\Dev\Deprecation;
35
use SilverStripe\ORM\DataList;
36
use SilverStripe\ORM\ValidationException;
37
use SilverStripe\View\ArrayData;
38
use Solarium\Core\Client\Adapter\Guzzle;
39
use Solarium\Core\Client\Client;
40
use Solarium\QueryType\Select\Query\Query;
41
use Solarium\QueryType\Select\Result\Result;
42
use Symfony\Component\EventDispatcher\EventDispatcher;
43
44
/**
45
 * Base for creating a new Solr core.
46
 *
47
 * Base index settings and methods. Should be extended with at least a name for the index.
48
 * This is an abstract class that can not be instantiated on it's own
49
 *
50
 * @package Firesphere\Solr\Search
51
 */
52
abstract class BaseIndex
53
{
54
    use Extensible;
55
    use Configurable;
56
    use Injectable;
57
    use GetterSetterTrait;
58
    use BaseIndexTrait;
59
60
    /**
61
     * Field types that can be added
62
     * Used in init to call build methods from configuration yml
63
     *
64
     * @array
65
     */
66
    private static $fieldTypes = [
67
        'FulltextFields',
68
        'SortFields',
69
        'FilterFields',
70
        'BoostedFields',
71
        'CopyFields',
72
        'DefaultField',
73
        'FacetFields',
74
        'StoredFields',
75
    ];
76
    /**
77
     * {@link SchemaFactory}
78
     *
79
     * @var SchemaFactory Schema factory for generating the schema
80
     */
81
    protected $schemaFactory;
82
    /**
83
     * {@link QueryComponentFactory}
84
     *
85
     * @var QueryComponentFactory Generator for all components
86
     */
87
    protected $queryFactory;
88
    /**
89
     * @var array The query terms as an array
90
     */
91
    protected $queryTerms = [];
92
    /**
93
     * @var Query Query that will hit the client
94
     */
95
    protected $clientQuery;
96
    /**
97
     * @var bool Signify if a retry should occur if nothing was found and there are suggestions to follow
98
     */
99
    private $retry = false;
100
101
    /**
102
     * BaseIndex constructor.
103
     */
104 54
    public function __construct()
105
    {
106
        // Set up the client
107 54
        $config = Config::inst()->get(SolrCoreService::class, 'config');
108 54
        $config['endpoint'] = $this->getConfig($config['endpoint']);
109 54
        $adapter = new Guzzle();
0 ignored issues
show
Deprecated Code introduced by
The class Solarium\Core\Client\Adapter\Guzzle has been deprecated: Deprecated since Solarium 5.2 and will be removed in Solarium 6. Use Psr18Adapter instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

109
        $adapter = /** @scrutinizer ignore-deprecated */ new Guzzle();
Loading history...
110 54
        $dispatcher = new EventDispatcher();
111 54
        $this->client = new Client($adapter, $dispatcher, $config);
112
113
        // Set up the schema service, only used in the generation of the schema
114 54
        $schemaFactory = Injector::inst()->get(SchemaFactory::class, false);
115 54
        $schemaFactory->setIndex($this);
116 54
        $schemaFactory->setStore(Director::isDev());
117 54
        $this->schemaFactory = $schemaFactory;
118 54
        $this->queryFactory = Injector::inst()->get(QueryComponentFactory::class, false);
119
120 54
        $this->extend('onBeforeInit');
121 54
        $this->init();
122 54
        $this->extend('onAfterInit');
123 54
    }
124
125
    /**
126
     * Build a full config for all given endpoints
127
     * This is to add the current index to e.g. an index or select
128
     *
129
     * @param array $endpoints
130
     * @return array
131
     */
132 54
    public function getConfig($endpoints): array
133
    {
134 54
        foreach ($endpoints as $host => $endpoint) {
135 54
            $endpoints[$host]['core'] = $this->getIndexName();
136
        }
137
138 54
        return $endpoints;
139
    }
140
141
    /**
142
     * Name of this index.
143
     *
144
     * @return string
145
     */
146
    abstract public function getIndexName();
147
148
    /**
149
     * Required to initialise the fields.
150
     * It's loaded in to the non-static properties for backward compatibility with FTS
151
     * Also, it's a tad easier to use this way, loading the other way around would be very
152
     * memory intensive, as updating the config for each item is not efficient
153
     */
154 50
    public function init()
155
    {
156 50
        $config = self::config()->get($this->getIndexName());
157 50
        if (!$config) {
158 50
            Deprecation::notice('5', 'Please set an index name and use a config yml');
159
        }
160
161 50
        if (!empty($this->getClasses())) {
162 50
            if (!$this->usedAllFields) {
163 50
                Deprecation::notice('5', 'It is advised to use a config YML for most cases');
164
            }
165
166 50
            return;
167
        }
168
169 42
        $this->initFromConfig($config);
170 42
    }
171
172
    /**
173
     * Generate the config from yml if possible
174
     * @param array|null $config
175
     */
176 42
    protected function initFromConfig($config): void
177
    {
178 42
        if (!$config || !array_key_exists('Classes', $config)) {
179 2
            throw new LogicException('No classes or config to index found!');
180
        }
181
182 42
        $this->setClasses($config['Classes']);
183
184
        // For backward compatibility, copy the config to the protected values
185
        // Saves doubling up further down the line
186 42
        foreach (self::$fieldTypes as $type) {
187 42
            if (array_key_exists($type, $config)) {
188 42
                $method = 'set' . $type;
189 42
                $this->$method($config[$type]);
190
            }
191
        }
192 42
    }
193
194
    /**
195
     * Default returns a SearchResult. It can return an ArrayData if FTS Compat is enabled
196
     *
197
     * @param BaseQuery $query
198
     * @return SearchResult|ArrayData|mixed
199
     * @throws GuzzleException
200
     * @throws ValidationException
201
     * @throws ReflectionException
202
     * @throws Exception
203
     */
204 6
    public function doSearch(BaseQuery $query)
205
    {
206 6
        SiteState::alterQuery($query);
207
        // Build the actual query parameters
208 6
        $this->clientQuery = $this->buildSolrQuery($query);
209
        // Set the sorting
210 6
        $this->clientQuery->addSorts($query->getSort());
211
212 6
        $this->extend('onBeforeSearch', $query, $this->clientQuery);
213
214
        try {
215 6
            $result = $this->client->select($this->clientQuery);
216
        } catch (Exception $error) {
217
            // @codeCoverageIgnoreStart
218
            $logger = new SolrLogger();
219
            $logger->saveSolrLog('Query');
220
            throw $error;
221
            // @codeCoverageIgnoreEnd
222
        }
223
224
        // Handle the after search first. This gets a raw search result
225 6
        $this->extend('onAfterSearch', $result);
226 6
        $searchResult = new SearchResult($result, $query, $this);
227 6
        if ($this->doRetry($query, $result, $searchResult)) {
228
            // We need to override the spellchecking with the previous spellcheck
229
            // @todo refactor this to a cleaner way
230 2
            $collation = $result->getSpellcheck();
231 2
            $retryResults = $this->spellcheckRetry($query, $searchResult);
232 2
            $this->retry = false;
233 2
            return $retryResults->setCollatedSpellcheck($collation);
234
        }
235
236
        // And then handle the search results, which is a useable object for SilverStripe
237 6
        $this->extend('updateSearchResults', $searchResult);
238
239 6
        return $searchResult;
240
    }
241
242
    /**
243
     * From the given BaseQuery, generate a Solarium ClientQuery object
244
     *
245
     * @param BaseQuery $query
246
     * @return Query
247
     */
248 6
    public function buildSolrQuery(BaseQuery $query): Query
249
    {
250 6
        $clientQuery = $this->client->createSelect();
251 6
        $factory = $this->buildFactory($query, $clientQuery);
252
253 6
        $clientQuery = $factory->buildQuery();
254 6
        $this->queryTerms = $factory->getQueryArray();
255
256 6
        $queryData = implode(' ', $this->queryTerms);
257 6
        $clientQuery->setQuery($queryData);
258
259 6
        return $clientQuery;
260
    }
261
262
    /**
263
     * Build a factory to use in the SolrQuery building. {@link static::buildSolrQuery()}
264
     *
265
     * @param BaseQuery $query
266
     * @param Query $clientQuery
267
     * @return QueryComponentFactory|mixed
268
     */
269 6
    protected function buildFactory(BaseQuery $query, Query $clientQuery)
270
    {
271 6
        $factory = $this->queryFactory;
272
273 6
        $helper = $clientQuery->getHelper();
274
275 6
        $factory->setQuery($query);
276 6
        $factory->setClientQuery($clientQuery);
277 6
        $factory->setHelper($helper);
278 6
        $factory->setIndex($this);
279
280 6
        return $factory;
281
    }
282
283
    /**
284
     * Check if the query should be retried with spellchecking
285
     * Conditions are:
286
     * It is not already a retry with spellchecking
287
     * Spellchecking is enabled
288
     * If spellchecking is enabled and nothing is found OR it should follow spellchecking none the less
289
     * There is a spellcheck output
290
     *
291
     * @param BaseQuery $query
292
     * @param Result $result
293
     * @param SearchResult $searchResult
294
     * @return bool
295
     */
296 6
    protected function doRetry(BaseQuery $query, Result $result, SearchResult $searchResult): bool
297
    {
298 6
        return !$this->retry &&
299 6
            $query->hasSpellcheck() &&
300 6
            ($query->shouldFollowSpellcheck() || $result->getNumFound() === 0) &&
301 6
            $searchResult->getCollatedSpellcheck();
302
    }
303
304
    /**
305
     * Retry the query with the first collated spellcheck found.
306
     *
307
     * @param BaseQuery $query
308
     * @param SearchResult $searchResult
309
     * @return SearchResult|mixed|ArrayData
310
     * @throws GuzzleException
311
     * @throws ValidationException
312
     * @throws ReflectionException
313
     */
314 2
    protected function spellcheckRetry(BaseQuery $query, SearchResult $searchResult)
315
    {
316 2
        $terms = $query->getTerms();
317 2
        $spellChecked = $searchResult->getCollatedSpellcheck();
318
        // Remove the fuzzyness from the collated check
319 2
        $term = preg_replace('/~\d+/', '', $spellChecked);
320 2
        $terms[0]['text'] = $term;
321 2
        $query->setTerms($terms);
322 2
        $this->retry = true;
323
324 2
        return $this->doSearch($query);
325
    }
326
327
    /**
328
     * Get all fields that are required for indexing in a unique way
329
     *
330
     * @return array
331
     */
332 10
    public function getFieldsForIndexing(): array
333
    {
334 10
        $facets = [];
335 10
        foreach ($this->getFacetFields() as $field) {
336 9
            $facets[] = $field['Field'];
337
        }
338
        // Return values to make the key reset
339
        // Only return unique values
340
        // And make it all a single array
341 10
        $fields = array_values(
342 10
            array_unique(
343 10
                array_merge(
344 10
                    $this->getFulltextFields(),
345 10
                    $this->getSortFields(),
346 10
                    $facets,
347 10
                    $this->getFilterFields()
348
                )
349
            )
350
        );
351
352 10
        $this->extend('updateFieldsForIndexing', $fields);
353
354 10
        return $fields;
355
    }
356
357
    /**
358
     * Upload config for this index to the given store
359
     *
360
     * @param ConfigStore $store
361
     */
362 36
    public function uploadConfig(ConfigStore $store): void
363
    {
364
        // @todo use types/schema/elevate rendering
365
        // Upload the config files for this index
366
        // Create a default schema which we can manage later
367 36
        $schema = (string)$this->schemaFactory->generateSchema();
368 36
        $store->uploadString(
369 36
            $this->getIndexName(),
370 36
            'schema.xml',
371 36
            $schema
372
        );
373
374 36
        $this->getSynonyms($store);
375
376
        // Upload additional files
377 36
        foreach (glob($this->schemaFactory->getExtrasPath() . '/*') as $file) {
378 36
            if (is_file($file)) {
379 36
                $store->uploadFile($this->getIndexName(), $file);
380
            }
381
        }
382 36
    }
383
384
    /**
385
     * Add synonyms. Public to be extendable
386
     *
387
     * @param ConfigStore $store Store to use to write synonyms
388
     * @param bool $defaults Include UK to US synonyms
389
     * @return string
390
     */
391 36
    public function getSynonyms($store = null, $defaults = true)
392
    {
393 36
        $synonyms = Synonyms::getSynonymsAsString($defaults);
394
        /** @var DataList|SearchSynonym[] $syn */
395 36
        $syn = SearchSynonym::get();
396 36
        foreach ($syn as $synonym) {
397 1
            $synonyms .= $synonym->getCombinedSynonym();
398
        }
399
400
        // Upload synonyms
401 36
        if ($store) {
402 36
            $store->uploadString(
403 36
                $this->getIndexName(),
404 36
                'synonyms.txt',
405 36
                $synonyms
406
            );
407
        }
408
409 36
        return $synonyms;
410
    }
411
412
    /**
413
     * Get the final, generated terms
414
     *
415
     * @return array
416
     */
417 2
    public function getQueryTerms(): array
418
    {
419 2
        return $this->queryTerms;
420
    }
421
422
    /**
423
     * Get the QueryComponentFactory. {@link QueryComponentFactory}
424
     *
425
     * @return QueryComponentFactory
426
     */
427 1
    public function getQueryFactory(): QueryComponentFactory
428
    {
429 1
        return $this->queryFactory;
430
    }
431
432
    /**
433
     * Retrieve the Solarium client Query object for this index operation
434
     *
435
     * @return Query
436
     */
437 1
    public function getClientQuery(): Query
438
    {
439 1
        return $this->clientQuery;
440
    }
441
442
    /**
443
     * @return bool
444
     */
445 6
    public function isRetry(): bool
446
    {
447 6
        return $this->retry;
448
    }
449
}
450