BaseIndex::initFromConfig()   A
last analyzed

Complexity

Conditions 5
Paths 4

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 5

Importance

Changes 0
Metric Value
cc 5
eloc 7
nc 4
nop 1
dl 0
loc 14
ccs 7
cts 7
cp 1
crap 5
rs 9.6111
c 0
b 0
f 0
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 Http\Discovery\HttpClientDiscovery;
26
use Http\Discovery\Psr17FactoryDiscovery;
27
use LogicException;
28
use ReflectionException;
29
use SilverStripe\Control\Director;
30
use SilverStripe\Core\Config\Config;
31
use SilverStripe\Core\Config\Configurable;
32
use SilverStripe\Core\Extensible;
33
use SilverStripe\Core\Injector\Injectable;
34
use SilverStripe\Core\Injector\Injector;
35
use SilverStripe\Dev\Deprecation;
36
use SilverStripe\ORM\DataList;
37
use SilverStripe\ORM\ValidationException;
38
use SilverStripe\View\ArrayData;
39
use Solarium\Client as SolariumClient;
40
use Solarium\Core\Client\Adapter\Psr18Adapter;
41
use Solarium\Exception\HttpException;
42
use Solarium\QueryType\Select\Query\Query;
43
use Solarium\QueryType\Select\Result\Result;
44
use Symfony\Component\EventDispatcher\EventDispatcher;
45
46
/**
47
 * Base for creating a new Solr core.
48
 *
49
 * Base index settings and methods. Should be extended with at least a name for the index.
50
 * This is an abstract class that can not be instantiated on it's own
51
 *
52
 * @package Firesphere\Solr\Search
53
 */
54
abstract class BaseIndex
55
{
56
    use Extensible;
57
    use Configurable;
58
    use Injectable;
59
    use GetterSetterTrait;
60
    use BaseIndexTrait;
61
62
    /**
63
     * Field types that can be added
64
     * Used in init to call build methods from configuration yml
65
     *
66
     * @array
67
     */
68
    private static $fieldTypes = [
69
        'FulltextFields',
70
        'SortFields',
71
        'FilterFields',
72
        'BoostedFields',
73
        'CopyFields',
74
        'DefaultField',
75
        'FacetFields',
76
        'StoredFields',
77
    ];
78
    /**
79
     * {@link SchemaFactory}
80
     *
81
     * @var SchemaFactory Schema factory for generating the schema
82
     */
83
    protected $schemaFactory;
84
    /**
85
     * {@link QueryComponentFactory}
86
     *
87
     * @var QueryComponentFactory Generator for all components
88
     */
89
    protected $queryFactory;
90
    /**
91
     * @var array The query terms as an array
92
     */
93
    protected $queryTerms = [];
94
    /**
95
     * @var Query Query that will hit the client
96
     */
97
    protected $clientQuery;
98
    /**
99
     * @var bool Signify if a retry should occur if nothing was found and there are suggestions to follow
100
     */
101
    private $retry = false;
102
103
    /**
104 54
     * BaseIndex constructor.
105
     */
106
    public function __construct()
107 54
    {
108 54
        // Set up the client
109 54
        $config = Config::inst()->get(SolrCoreService::class, 'config');
110 54
        $config['endpoint'] = $this->getConfig($config['endpoint']);
111 54
        $this->client = (new SolrCoreService())->getClient();
112
        $this->client->setOptions($config);
113
114 54
        // Set up the schema service, only used in the generation of the schema
115 54
        /** @var SchemaFactory $schemaFactory */
116 54
        $schemaFactory = Injector::inst()->get(SchemaFactory::class, false);
117 54
        $schemaFactory->setIndex($this);
118 54
        $schemaFactory->setStore(Director::isDev());
119
        $this->schemaFactory = $schemaFactory;
120 54
        $this->queryFactory = Injector::inst()->get(QueryComponentFactory::class, false);
121 54
122 54
        $this->extend('onBeforeInit');
123 54
        $this->init();
124
        $this->extend('onAfterInit');
125
    }
126
127
    /**
128
     * Build a full config for all given endpoints
129
     * This is to add the current index to e.g. an index or select
130
     *
131
     * @param array $endpoints
132 54
     * @return array
133
     */
134 54
    public function getConfig($endpoints): array
135 54
    {
136
        foreach ($endpoints as $host => $endpoint) {
137
            $endpoints[$host]['core'] = $this->getIndexName();
138 54
        }
139
140
        return $endpoints;
141
    }
142
143
    /**
144
     * Name of this index.
145
     *
146
     * @return string
147
     */
148
    abstract public function getIndexName();
149
150
    /**
151
     * Required to initialise the fields.
152
     * It's loaded in to the non-static properties for backward compatibility with FTS
153
     * Also, it's a tad easier to use this way, loading the other way around would be very
154 50
     * memory intensive, as updating the config for each item is not efficient
155
     */
156 50
    public function init()
157 50
    {
158 50
        $config = self::config()->get($this->getIndexName());
159
        if (!$config) {
160
            Deprecation::notice('5', 'Please set an index name and use a config yml');
161 50
        }
162 50
163 50
        if (!empty($this->getClasses())) {
164
            if (!$this->usedAllFields) {
165
                Deprecation::notice('5', 'It is advised to use a config YML for most cases');
166 50
            }
167
168
            return;
169 42
        }
170 42
171
        $this->initFromConfig($config);
172
    }
173
174
    /**
175
     * Generate the config from yml if possible
176 42
     * @param array|null $config
177
     */
178 42
    protected function initFromConfig($config): void
179 2
    {
180
        if (!$config || !array_key_exists('Classes', $config)) {
181
            throw new LogicException('No classes or config to index found!');
182 42
        }
183
184
        $this->setClasses($config['Classes']);
185
186 42
        // For backward compatibility, copy the config to the protected values
187 42
        // Saves doubling up further down the line
188 42
        foreach (self::$fieldTypes as $type) {
189 42
            if (array_key_exists($type, $config)) {
190
                $method = 'set' . $type;
191
                $this->$method($config[$type]);
192 42
            }
193
        }
194
    }
195
196
    /**
197
     * Default returns a SearchResult. It can return an ArrayData if FTS Compat is enabled
198
     *
199
     * @param BaseQuery $query
200
     * @return SearchResult|ArrayData|mixed
201
     * @throws HTTPException
202
     * @throws ValidationException
203
     * @throws ReflectionException
204 6
     * @throws Exception
205
     */
206 6
    public function doSearch(BaseQuery $query)
207
    {
208 6
        SiteState::alterQuery($query);
209
        // Build the actual query parameters
210 6
        $this->clientQuery = $this->buildSolrQuery($query);
211
        // Set the sorting
212 6
        $this->clientQuery->addSorts($query->getSort());
213
214
        $this->extend('onBeforeSearch', $query, $this->clientQuery);
215 6
216
        try {
217
            $result = $this->client->select($this->clientQuery);
218
        } catch (Exception $error) {
219
            // @codeCoverageIgnoreStart
220
            $logger = new SolrLogger();
221
            $logger->saveSolrLog('Query');
222
            throw $error;
223
            // @codeCoverageIgnoreEnd
224
        }
225 6
226 6
        // Handle the after search first. This gets a raw search result
227 6
        $this->extend('onAfterSearch', $result);
228
        $searchResult = new SearchResult($result, $query, $this);
229
        if ($this->doRetry($query, $result, $searchResult)) {
230 2
            // We need to override the spellchecking with the previous spellcheck
231 2
            // @todo refactor this to a cleaner way
232 2
            $collation = $result->getSpellcheck();
233 2
            $retryResults = $this->spellcheckRetry($query, $searchResult);
234
            $this->retry = false;
235
            return $retryResults->setCollatedSpellcheck($collation);
236
        }
237 6
238
        // And then handle the search results, which is a useable object for SilverStripe
239 6
        $this->extend('updateSearchResults', $searchResult);
240
241
        return $searchResult;
242
    }
243
244
    /**
245
     * From the given BaseQuery, generate a Solarium ClientQuery object
246
     *
247
     * @param BaseQuery $query
248 6
     * @return Query
249
     */
250 6
    public function buildSolrQuery(BaseQuery $query): Query
251 6
    {
252
        $clientQuery = $this->client->createSelect();
253 6
        $factory = $this->buildFactory($query, $clientQuery);
254 6
255
        $clientQuery = $factory->buildQuery();
256 6
        $this->queryTerms = $factory->getQueryArray();
257 6
258
        $queryData = implode(' ', $this->queryTerms);
259 6
        $clientQuery->setQuery($queryData);
260
261
        return $clientQuery;
262
    }
263
264
    /**
265
     * Build a factory to use in the SolrQuery building. {@link static::buildSolrQuery()}
266
     *
267
     * @param BaseQuery $query
268
     * @param Query $clientQuery
269 6
     * @return QueryComponentFactory|mixed
270
     */
271 6
    protected function buildFactory(BaseQuery $query, Query $clientQuery)
272
    {
273 6
        $factory = $this->queryFactory;
274
275 6
        $helper = $clientQuery->getHelper();
276 6
277 6
        $factory->setQuery($query);
278 6
        $factory->setClientQuery($clientQuery);
279
        $factory->setHelper($helper);
280 6
        $factory->setIndex($this);
281
282
        return $factory;
283
    }
284
285
    /**
286
     * Check if the query should be retried with spellchecking
287
     * Conditions are:
288
     * It is not already a retry with spellchecking
289
     * Spellchecking is enabled
290
     * If spellchecking is enabled and nothing is found OR it should follow spellchecking none the less
291
     * There is a spellcheck output
292
     *
293
     * @param BaseQuery $query
294
     * @param Result $result
295
     * @param SearchResult $searchResult
296 6
     * @return bool
297
     */
298 6
    protected function doRetry(BaseQuery $query, Result $result, SearchResult $searchResult): bool
299 6
    {
300 6
        return !$this->retry &&
301 6
            $query->hasSpellcheck() &&
302
            ($query->shouldFollowSpellcheck() || $result->getNumFound() === 0) &&
303
            $searchResult->getCollatedSpellcheck();
304
    }
305
306
    /**
307
     * Retry the query with the first collated spellcheck found.
308
     *
309
     * @param BaseQuery $query
310
     * @param SearchResult $searchResult
311
     * @return SearchResult|mixed|ArrayData
312
     * @throws HTTPException
313
     * @throws ValidationException
314 2
     * @throws ReflectionException
315
     */
316 2
    protected function spellcheckRetry(BaseQuery $query, SearchResult $searchResult)
317 2
    {
318
        $terms = $query->getTerms();
319 2
        $spellChecked = $searchResult->getCollatedSpellcheck();
320 2
        // Remove the fuzzyness from the collated check
321 2
        $term = preg_replace('/~\d+/', '', $spellChecked);
322 2
        $terms[0]['text'] = $term;
323
        $query->setTerms($terms);
324 2
        $this->retry = true;
325
326
        return $this->doSearch($query);
327
    }
328
329
    /**
330
     * Get all fields that are required for indexing in a unique way
331
     *
332 10
     * @return array
333
     */
334 10
    public function getFieldsForIndexing(): array
335 10
    {
336 9
        $facets = [];
337
        foreach ($this->getFacetFields() as $field) {
338
            $facets[] = $field['Field'];
339
        }
340
        // Return values to make the key reset
341 10
        // Only return unique values
342 10
        // And make it all a single array
343 10
        $fields = array_values(
344 10
            array_unique(
345 10
                array_merge(
346 10
                    $this->getFulltextFields(),
347 10
                    $this->getSortFields(),
348
                    $facets,
349
                    $this->getFilterFields()
350
                )
351
            )
352 10
        );
353
354 10
        $this->extend('updateFieldsForIndexing', $fields);
355
356
        return $fields;
357
    }
358
359
    /**
360
     * Upload config for this index to the given store
361
     *
362 36
     * @param ConfigStore $store
363
     */
364
    public function uploadConfig(ConfigStore $store): void
365
    {
366
        // @todo use types/schema/elevate rendering
367 36
        // Upload the config files for this index
368 36
        // Create a default schema which we can manage later
369 36
        $schema = (string)$this->schemaFactory->generateSchema();
370 36
        $store->uploadString(
371 36
            $this->getIndexName(),
372
            'schema.xml',
373
            $schema
374 36
        );
375
376
        $this->getSynonyms($store);
377 36
378 36
        // Upload additional files
379 36
        foreach (glob($this->schemaFactory->getExtrasPath() . '/*') as $file) {
380
            if (is_file($file)) {
381
                $store->uploadFile($this->getIndexName(), $file);
382 36
            }
383
        }
384
    }
385
386
    /**
387
     * Add synonyms. Public to be extendable
388
     *
389
     * @param ConfigStore $store Store to use to write synonyms
390
     * @param bool $defaults Include UK to US synonyms
391 36
     * @return string
392
     */
393 36
    public function getSynonyms($store = null, $defaults = true)
394
    {
395 36
        $synonyms = Synonyms::getSynonymsAsString($defaults);
396 36
        /** @var DataList|SearchSynonym[] $syn */
397 1
        $syn = SearchSynonym::get();
398
        foreach ($syn as $synonym) {
399
            $synonyms .= $synonym->getCombinedSynonym();
400
        }
401 36
402 36
        // Upload synonyms
403 36
        if ($store) {
404 36
            $store->uploadString(
405 36
                $this->getIndexName(),
406
                'synonyms.txt',
407
                $synonyms
408
            );
409 36
        }
410
411
        return $synonyms;
412
    }
413
414
    /**
415
     * Get the final, generated terms
416
     *
417 2
     * @return array
418
     */
419 2
    public function getQueryTerms(): array
420
    {
421
        return $this->queryTerms;
422
    }
423
424
    /**
425
     * Get the QueryComponentFactory. {@link QueryComponentFactory}
426
     *
427 1
     * @return QueryComponentFactory
428
     */
429 1
    public function getQueryFactory(): QueryComponentFactory
430
    {
431
        return $this->queryFactory;
432
    }
433
434
    /**
435
     * Retrieve the Solarium client Query object for this index operation
436
     *
437 1
     * @return Query
438
     */
439 1
    public function getClientQuery(): Query
440
    {
441
        return $this->clientQuery;
442
    }
443
444
    /**
445 6
     * @return bool
446
     */
447 6
    public function isRetry(): bool
448
    {
449
        return $this->retry;
450
    }
451
}
452