Passed
Push — hans/Index-all-fluent-options ( 33af16...2afa88 )
by Simon
07:48
created

BaseIndex::doSearch()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 29
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 3.072

Importance

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