Passed
Pull Request — master (#159)
by Simon
10:10 queued 07:57
created

BaseIndex::doSearch()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 26
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3.0884

Importance

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