Passed
Push — hans/state-improvements ( 58acba...11f472 )
by Simon
07:14
created

BaseIndex::initFromConfig()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4.0218

Importance

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