Passed
Push — sheepy/elevation-configuration ( f8fadb...77a4b0 )
by Marco
07:42
created

BaseIndex::spellcheckRetry()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 1

Importance

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