Passed
Push — hans/searchhistory ( a34d75 )
by Simon
06:36
created

BaseIndex   A

Complexity

Total Complexity 31

Size/Duplication

Total Lines 347
Duplicated Lines 0 %

Test Coverage

Coverage 95.76%

Importance

Changes 11
Bugs 1 Features 0
Metric Value
wmc 31
eloc 121
c 11
b 1
f 0
dl 0
loc 347
ccs 113
cts 118
cp 0.9576
rs 9.92

14 Methods

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