Passed
Push — hans/bufferadd ( d81861...271d1a )
by Simon
06:32 queued 01:27
created

BaseIndex::doSearch()   A

Complexity

Conditions 3
Paths 9

Size

Total Lines 31
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 3.0123

Importance

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