Passed
Push — hans/Added-endpoints ( 66e262 )
by Simon
07:19
created

BaseIndex   A

Complexity

Total Complexity 33

Size/Duplication

Total Lines 367
Duplicated Lines 0 %

Test Coverage

Coverage 96.67%

Importance

Changes 12
Bugs 1 Features 0
Metric Value
eloc 122
c 12
b 1
f 0
dl 0
loc 367
ccs 116
cts 120
cp 0.9667
rs 9.76
wmc 33

14 Methods

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