Passed
Push — master ( bc16c5...fce4c6 )
by Simon
06:43
created

BaseIndex::doRetry()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 5

Importance

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